From 4acc884d97c35313c8092f22f877bfe13124bcb9 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Tue, 8 Sep 2026 14:55:28 -0400 Subject: [PATCH 1/9] Extend V10 visibility and expose trusted invocation authority --- Cargo.lock | 7 + Cargo.toml | 1 + crates/bindings-cpp/README.md | 25 +- .../include/spacetimedb/abi/FFI.h | 1 + .../include/spacetimedb/abi/abi.h | 6 +- .../include/spacetimedb/auth_ctx.h | 90 +++-- .../include/spacetimedb/function_visibility.h | 6 + .../internal/autogen/FunctionVisibility.g.h | 2 + .../autogen/RawModuleDefV10Section.g.h | 2 +- .../spacetimedb/internal/v10_builder.h | 5 +- .../include/spacetimedb/jwt_claims.h | 12 +- .../bindings-cpp/include/spacetimedb/macros.h | 10 +- .../include/spacetimedb/procedure_context.h | 19 +- .../bindings-cpp/src/internal/v10_builder.cpp | 46 ++- crates/bindings-cpp/tests/unit/CMakeLists.txt | 13 +- .../tests/unit/environment_unit_tests.cpp | 50 --- .../unit/function_visibility_unit_tests.cpp | 98 +++++ .../tests/unit/hosted_auth_unit_tests.cpp | 137 +++++++ crates/bindings-csharp/Codegen.Tests/Tests.cs | 59 +++ .../diag/snapshots/Module#FFI.verified.cs | 4 +- .../server/snapshots/Module#FFI.verified.cs | 2 +- crates/bindings-csharp/Codegen/Diag.cs | 9 + crates/bindings-csharp/Codegen/Module.cs | 52 ++- crates/bindings-csharp/README.md | 22 +- .../Runtime.Tests/FunctionVisibilityTests.cs | 68 ++++ .../Runtime.Tests/HostedAuthTests.cs | 43 +++ crates/bindings-csharp/Runtime/Attrs.cs | 12 + crates/bindings-csharp/Runtime/AuthCtx.cs | 34 +- .../Internal/Autogen/FunctionVisibility.g.cs | 2 + .../Autogen/RawModuleDefV10Section.g.cs | 3 +- .../bindings-csharp/Runtime/Internal/FFI.cs | 11 + .../Runtime/Internal/Module.cs | 34 +- crates/bindings-csharp/Runtime/JwtClaims.cs | 4 +- crates/bindings-csharp/Runtime/Runtime.csproj | 1 + crates/bindings-csharp/Runtime/bindings.c | 4 + crates/bindings-macro/src/procedure.rs | 37 +- crates/bindings-macro/src/reducer.rs | 89 +++++ crates/bindings-sys/src/lib.rs | 16 +- crates/bindings-typescript/README.md | 20 + .../src/lib/autogen/types.ts | 3 + .../bindings-typescript/src/lib/reducers.ts | 4 +- crates/bindings-typescript/src/lib/schema.ts | 2 + .../src/server/function_visibility.ts | 24 ++ .../bindings-typescript/src/server/index.ts | 1 + .../src/server/procedures.ts | 38 +- .../src/server/reducers.ts | 18 +- .../bindings-typescript/src/server/runtime.ts | 47 +-- .../bindings-typescript/src/server/schema.ts | 10 +- .../bindings-typescript/src/server/sys.d.ts | 5 +- .../tests/__mocks__/spacetime-auth.ts | 2 + .../tests/hosted_auth.test.ts | 303 +++++++++++++++ crates/bindings-typescript/vitest.config.ts | 4 + crates/bindings/src/http.rs | 8 +- crates/bindings/src/lib.rs | 139 +++++-- crates/bindings/src/rt.rs | 20 +- .../tests/pass/function_visibility.rs | 62 +++ crates/bindings/tests/ui/tables.stderr | 16 +- crates/cli/src/subcommands/generate.rs | 2 +- crates/client-api/src/routes/database.rs | 3 +- crates/client-api/src/routes/mcp.rs | 4 +- crates/codegen/src/util.rs | 88 ++++- crates/core/src/host/host_controller.rs | 3 + .../host_controller/invocation_flags_tests.rs | 139 +++++++ crates/core/src/host/instance_env.rs | 11 + crates/core/src/host/mod.rs | 1 + crates/core/src/host/module_host.rs | 16 +- crates/core/src/host/v8/mod.rs | 2 + crates/core/src/host/v8/syscall/common.rs | 1 + crates/core/src/host/v8/syscall/mod.rs | 2 +- crates/core/src/host/v8/syscall/v1.rs | 1 + crates/core/src/host/v8/syscall/v2.rs | 13 + crates/core/src/host/wasm_common.rs | 2 +- .../src/host/wasm_common/module_host_actor.rs | 16 + .../src/host/wasmtime/wasm_instance_env.rs | 8 + .../core/src/host/wasmtime/wasmtime_module.rs | 2 + crates/lib/src/db/raw_def/v10.rs | 224 ++++++++++- crates/schema/src/auto_migrate.rs | 61 +++ crates/schema/src/auto_migrate/formatter.rs | 9 + .../src/auto_migrate/termcolor_formatter.rs | 9 + crates/schema/src/def.rs | 168 +++++++-- crates/schema/src/def/validate/v10.rs | 355 +++++++++++++++++- crates/schema/src/def/validate/v9.rs | 7 +- crates/schema/src/error.rs | 8 + .../src/subcommands/extract_schema.rs | 4 +- crates/testing/tests/invocation_flags.rs | 76 ++++ modules/invocation-flags-test/Cargo.toml | 13 + modules/invocation-flags-test/src/lib.rs | 78 ++++ modules/module-test-ts/src/index.ts | 2 +- modules/module-test/src/lib.rs | 2 +- modules/sdk-test-procedure-ts/src/index.ts | 2 +- modules/sdk-test-procedure/src/lib.rs | 2 +- .../procedure-client/src/test_handlers.rs | 14 +- 92 files changed, 2754 insertions(+), 356 deletions(-) create mode 100644 crates/bindings-cpp/include/spacetimedb/function_visibility.h delete mode 100644 crates/bindings-cpp/tests/unit/environment_unit_tests.cpp create mode 100644 crates/bindings-cpp/tests/unit/function_visibility_unit_tests.cpp create mode 100644 crates/bindings-cpp/tests/unit/hosted_auth_unit_tests.cpp create mode 100644 crates/bindings-csharp/Runtime.Tests/FunctionVisibilityTests.cs create mode 100644 crates/bindings-csharp/Runtime.Tests/HostedAuthTests.cs create mode 100644 crates/bindings-typescript/src/server/function_visibility.ts create mode 100644 crates/bindings-typescript/tests/__mocks__/spacetime-auth.ts create mode 100644 crates/bindings-typescript/tests/hosted_auth.test.ts create mode 100644 crates/bindings/tests/pass/function_visibility.rs create mode 100644 crates/core/src/host/host_controller/invocation_flags_tests.rs create mode 100644 crates/testing/tests/invocation_flags.rs create mode 100644 modules/invocation-flags-test/Cargo.toml create mode 100644 modules/invocation-flags-test/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 9b030bc73ff..65172309213 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3608,6 +3608,13 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "invocation-flags-test" +version = "0.0.0" +dependencies = [ + "spacetimedb", +] + [[package]] name = "ipnet" version = "2.11.0" diff --git a/Cargo.toml b/Cargo.toml index afdde4e3253..ba682e45ba5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,7 @@ members = [ "modules/perf-test", "modules/module-test", "modules/environment-test", + "modules/invocation-flags-test", "templates/basic-rs/spacetimedb", "templates/chat-console-rs/spacetimedb", "modules/sdk-test", diff --git a/crates/bindings-cpp/README.md b/crates/bindings-cpp/README.md index ef31361c10b..4fbfc2a2d86 100644 --- a/crates/bindings-cpp/README.md +++ b/crates/bindings-cpp/README.md @@ -2,6 +2,30 @@ The SpacetimeDB C++ Module Library provides a modern C++20 API for building SpacetimeDB modules that run inside the database as WebAssembly. +## Function visibility and invocation authentication + +Apply `SPACETIMEDB_FUNCTION_VISIBILITY(name, Public)`, `Private`, or `Internal` +to a reducer or procedure after its definition: + +```cpp +SPACETIMEDB_REDUCER(process_jobs, ReducerContext ctx) { + return Ok(); +} +SPACETIMEDB_FUNCTION_VISIBILITY(process_jobs, Internal); +``` + +Omission means public for ordinary functions and private for scheduled functions. +An explicit choice is preserved when the function is scheduled. Lifecycle +reducers permit only omission or `Internal` and can only run for their host +lifecycle event. Internal functions require verified internal authority. Private +functions also admit the owner, and public functions admit any client. + +`ctx.sender_auth().is_internal()` captures the host's invocation authority. It is +independent of connection and JWT presence, so an internal call can have a JWT. +JWT identity is the verified sender supplied by the host. Procedures preserve +this authentication in `with_tx` and `try_with_tx`. Newly compiled modules emit +schema V10 and advertise `hosted_auth_v1`, requiring a compatible host. + ## Current State This library provides a production-ready C++ bindings for SpacetimeDB with complete type system support: @@ -274,4 +298,3 @@ See the `modules/*-cpp/src/` directory for example modules: ## Contributing This library is part of the SpacetimeDB project. Please see the main repository for contribution guidelines. - diff --git a/crates/bindings-cpp/include/spacetimedb/abi/FFI.h b/crates/bindings-cpp/include/spacetimedb/abi/FFI.h index f3ad213452f..9133eed5863 100644 --- a/crates/bindings-cpp/include/spacetimedb/abi/FFI.h +++ b/crates/bindings-cpp/include/spacetimedb/abi/FFI.h @@ -74,6 +74,7 @@ using ::identity; // ===== JWT ===== using ::get_jwt; using ::env_get; +using ::get_call_auth_flags; // ===== Procedure Transactions ===== using ::procedure_start_mut_tx; diff --git a/crates/bindings-cpp/include/spacetimedb/abi/abi.h b/crates/bindings-cpp/include/spacetimedb/abi/abi.h index 285974cff3c..02791f73126 100644 --- a/crates/bindings-cpp/include/spacetimedb/abi/abi.h +++ b/crates/bindings-cpp/include/spacetimedb/abi/abi.h @@ -39,9 +39,10 @@ #define STDB_IMPORT_10_5(name) \ __attribute__((import_module("spacetime_10.5"), import_name(#name))) extern -// ABI10.6 is reserved for the separate invocation-authority extension. #define STDB_IMPORT_10_7(name) \ __attribute__((import_module("spacetime_10.7"), import_name(#name))) extern +#define STDB_IMPORT_10_6(name) \ + __attribute__((import_module("spacetime_10.6"), import_name(#name))) extern // Import opaque types into global namespace for C compatibility using SpacetimeDB::Status; @@ -65,6 +66,9 @@ extern "C" { STDB_IMPORT_10_7(env_get) Status env_get(const uint8_t* key, uint32_t key_len, BytesSource* out); +// Verified invocation authority. Bit 0 is INTERNAL; JWT presence is independent. +STDB_IMPORT_10_6(get_call_auth_flags) +uint32_t get_call_auth_flags(); // ===== Table and Index Management ===== STDB_IMPORT(table_id_from_name) diff --git a/crates/bindings-cpp/include/spacetimedb/auth_ctx.h b/crates/bindings-cpp/include/spacetimedb/auth_ctx.h index 00a4898e020..a8ae7fc62c9 100644 --- a/crates/bindings-cpp/include/spacetimedb/auth_ctx.h +++ b/crates/bindings-cpp/include/spacetimedb/auth_ctx.h @@ -28,22 +28,25 @@ struct ConnectionId; class AuthCtx { private: bool is_internal_; + std::optional verified_sender_; mutable std::shared_ptr> jwt_; std::function()> jwt_loader_; // Private constructor used by factory methods - AuthCtx(bool is_internal, std::function()> loader); + AuthCtx(bool is_internal, std::function()> loader, + std::optional verified_sender = std::nullopt); + static AuthCtx from_connection_with_flags(ConnectionId connection_id, Identity sender, uint32_t flags); public: /** * @brief Creates an AuthCtx from an optional ConnectionId. * * If the connection_id is present, creates an AuthCtx that will load the JWT. - * If the connection_id is absent, creates an internal AuthCtx. + * Internal authority is captured from the host, independently of connection presence. * * @param connection_id Optional connection ID - * @param sender The identity of the caller (already derived from JWT claims by the host) - * @return An AuthCtx based on the connection_id + * @param sender The verified caller Identity supplied by the host + * @return An AuthCtx with captured invocation authority and lazy JWT loading */ static AuthCtx from_connection_id_opt(std::optional connection_id, Identity sender); @@ -63,11 +66,10 @@ class AuthCtx { * This is primarily used for testing purposes, allowing you to create * an AuthCtx with specific JWT claims without needing a real connection. * - * Note: The Identity must be computed by calling the host function, - * as we cannot compute Blake3 hashes in WASM. + * The Identity must be the verified sender supplied by the host. * * @param jwt_payload The raw JWT payload (JSON claims) - * @param identity The identity derived from the JWT's issuer and subject + * @param identity The verified sender Identity * @return An AuthCtx with the provided JWT */ static AuthCtx from_jwt_payload(std::string jwt_payload, Identity identity); @@ -76,11 +78,10 @@ class AuthCtx { * @brief Creates an AuthCtx that reads the JWT for the given connection ID. * * The JWT will be lazily loaded from the host when first accessed. - * The identity parameter is the sender's identity, already derived from - * JWT claims by the host (using Blake3 hashing). + * The identity parameter is the verified sender supplied by the host. * * @param connection_id The connection ID to load the JWT for - * @param sender The identity of the caller (already derived from JWT claims by the host) + * @param sender The verified sender Identity supplied by the host * @return An AuthCtx that will load the JWT on demand */ static AuthCtx from_connection_id(ConnectionId connection_id, Identity sender); @@ -93,9 +94,9 @@ class AuthCtx { bool is_internal() const { return is_internal_; } /** - * @brief Checks if there is a JWT without loading it. + * @brief Checks if there is a JWT, loading it lazily if necessary. * - * If is_internal() returns true, this will return false. + * Independent of is_internal(). Internal calls can also have a JWT. * * @return true if a JWT is available */ @@ -113,9 +114,8 @@ class AuthCtx { /** * @brief Gets the caller's identity. * - * For internal calls, this returns the database's identity. - * For external calls, this returns the identity derived from the JWT - * (based on the issuer and subject claims). + * Returns the verified sender captured when constructing the context, + * independently of JWT presence or token claims. * * @return The caller's Identity */ @@ -126,16 +126,16 @@ class AuthCtx { // INLINE IMPLEMENTATIONS // ============================================================================ -constexpr uint16_t ERROR_BUFFER_TOO_SMALL = 11; - -inline AuthCtx::AuthCtx(bool is_internal, std::function()> loader) - : is_internal_(is_internal), jwt_loader_(std::move(loader)) {} +inline AuthCtx::AuthCtx(bool is_internal, std::function()> loader, + std::optional verified_sender) + : is_internal_(is_internal), verified_sender_(std::move(verified_sender)), jwt_loader_(std::move(loader)) {} inline AuthCtx AuthCtx::from_connection_id_opt(std::optional connection_id, Identity sender) { + const auto flags = FFI::get_call_auth_flags(); if (connection_id.has_value()) { - return from_connection_id(*connection_id, std::move(sender)); + return from_connection_with_flags(*connection_id, std::move(sender), flags); } else { - return internal(); + return AuthCtx((flags & 1) != 0, []() -> std::optional { return std::nullopt; }, sender); } } @@ -144,13 +144,17 @@ inline AuthCtx AuthCtx::internal() { } inline AuthCtx AuthCtx::from_jwt_payload(std::string jwt_payload, Identity identity) { - return AuthCtx(false, [payload = std::move(jwt_payload), id = std::move(identity)]() mutable -> std::optional { + return AuthCtx(false, [payload = std::move(jwt_payload), id = identity]() mutable -> std::optional { return JwtClaims(std::move(payload), std::move(id)); - }); + }, identity); } inline AuthCtx AuthCtx::from_connection_id(ConnectionId connection_id, Identity sender) { - return AuthCtx(false, [connection_id, sender]() -> std::optional { + return from_connection_with_flags(connection_id, std::move(sender), FFI::get_call_auth_flags()); +} + +inline AuthCtx AuthCtx::from_connection_with_flags(ConnectionId connection_id, Identity sender, uint32_t flags) { + return AuthCtx((flags & 1) != 0, [connection_id, sender]() -> std::optional { // Call the host FFI to get the JWT BytesSource jwt_source; @@ -169,35 +173,24 @@ inline AuthCtx AuthCtx::from_connection_id(ConnectionId connection_id, Identity } // Read the JWT payload from the BytesSource - std::vector buffer; - buffer.resize(4096); // Start with 4KB buffer - - size_t buffer_len = buffer.size(); - int16_t result = bytes_source_read(jwt_source, buffer.data(), &buffer_len); - - while (result == ERROR_BUFFER_TOO_SMALL) { - buffer.resize(buffer.size() * 2); - buffer_len = buffer.size(); - result = bytes_source_read(jwt_source, buffer.data(), &buffer_len); + std::array buffer; + std::string jwt_payload; + for (;;) { + size_t buffer_len = buffer.size(); + const auto result = bytes_source_read(jwt_source, buffer.data(), &buffer_len); + if (result != 0 && result != -1) return std::nullopt; + jwt_payload.append(reinterpret_cast(buffer.data()), buffer_len); + // -1 is successful exhaustion and may include the final payload bytes. + if (result == -1) break; + if (buffer_len == 0) return std::nullopt; } - - if (result < 0) { - return std::nullopt; - } - - // Convert bytes to string - std::string jwt_payload(buffer.begin(), buffer.begin() + buffer_len); - - // Use the provided sender identity (already computed by host from JWT claims) + if (jwt_payload.empty()) return std::nullopt; + // Token claims cannot override the verified sender, including hosted tokens. return JwtClaims(std::move(jwt_payload), sender); - }); + }, sender); } inline bool AuthCtx::has_jwt() const { - if (is_internal_) { - return false; - } - // Load the JWT if not already loaded, then check if it has a value // This ensures has_jwt() and get_jwt() are consistent return get_jwt().has_value(); @@ -211,6 +204,7 @@ inline const std::optional& AuthCtx::get_jwt() const { } inline Identity AuthCtx::get_caller_identity() const { + if (verified_sender_.has_value()) return *verified_sender_; if (is_internal_) { // Return database identity for internal calls std::array identity_bytes; diff --git a/crates/bindings-cpp/include/spacetimedb/function_visibility.h b/crates/bindings-cpp/include/spacetimedb/function_visibility.h new file mode 100644 index 00000000000..9bd36e19e48 --- /dev/null +++ b/crates/bindings-cpp/include/spacetimedb/function_visibility.h @@ -0,0 +1,6 @@ +#pragma once + +namespace SpacetimeDB { +// Omission preserves the host default: Public ordinarily, Private when scheduled. +enum class FunctionVisibility { Public, Private, Internal }; +} diff --git a/crates/bindings-cpp/include/spacetimedb/internal/autogen/FunctionVisibility.g.h b/crates/bindings-cpp/include/spacetimedb/internal/autogen/FunctionVisibility.g.h index 423276de9b4..9795b3bd2d7 100644 --- a/crates/bindings-cpp/include/spacetimedb/internal/autogen/FunctionVisibility.g.h +++ b/crates/bindings-cpp/include/spacetimedb/internal/autogen/FunctionVisibility.g.h @@ -18,5 +18,7 @@ namespace SpacetimeDB::Internal { enum class FunctionVisibility : uint8_t { Private = 0, ClientCallable = 1, + Internal = 2, + ExplicitClientCallable = 3, }; } // namespace SpacetimeDB::Internal diff --git a/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawModuleDefV10Section.g.h b/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawModuleDefV10Section.g.h index ea2e4b5ec85..d7002058c59 100644 --- a/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawModuleDefV10Section.g.h +++ b/crates/bindings-cpp/include/spacetimedb/internal/autogen/RawModuleDefV10Section.g.h @@ -30,5 +30,5 @@ namespace SpacetimeDB::Internal { -SPACETIMEDB_INTERNAL_TAGGED_ENUM(RawModuleDefV10Section, SpacetimeDB::Internal::Typespace, std::vector, std::vector, std::vector, std::vector, std::vector, std::vector, std::vector, std::vector, SpacetimeDB::Internal::CaseConversionPolicy, SpacetimeDB::Internal::ExplicitNames, std::vector, std::vector, std::vector, std::vector) +SPACETIMEDB_INTERNAL_TAGGED_ENUM(RawModuleDefV10Section, SpacetimeDB::Internal::Typespace, std::vector, std::vector, std::vector, std::vector, std::vector, std::vector, std::vector, std::vector, SpacetimeDB::Internal::CaseConversionPolicy, SpacetimeDB::Internal::ExplicitNames, std::vector, std::vector, std::vector, std::vector, std::vector) } // namespace SpacetimeDB::Internal diff --git a/crates/bindings-cpp/include/spacetimedb/internal/v10_builder.h b/crates/bindings-cpp/include/spacetimedb/internal/v10_builder.h index 235b5e5f680..f398a4eb9c2 100644 --- a/crates/bindings-cpp/include/spacetimedb/internal/v10_builder.h +++ b/crates/bindings-cpp/include/spacetimedb/internal/v10_builder.h @@ -13,6 +13,7 @@ #include #include "../bsatn/bsatn.h" #include "../database.h" +#include "../function_visibility.h" #include "autogen/CaseConversionPolicy.g.h" #include "autogen/ExplicitNameEntry.g.h" #include "autogen/NameMapping.g.h" @@ -49,6 +50,7 @@ void fail_reducer(std::string message); namespace Internal { +// Builds the V10 module definition with explicit function visibility. class V10Builder { public: V10Builder() = default; @@ -437,7 +439,7 @@ class V10Builder { RawReducerDefV10 reducer_def{ reducer_name, ProductType{}, - FunctionVisibility::Private, + FunctionVisibility::Internal, MakeUnitAlgebraicType(), MakeStringAlgebraicType(), }; @@ -646,6 +648,7 @@ class V10Builder { void RegisterExplicitTableName(const std::string& source_name, const std::string& canonical_name); void RegisterExplicitFunctionName(const std::string& source_name, const std::string& canonical_name); + void SetFunctionVisibility(const std::string& source_name, ::SpacetimeDB::FunctionVisibility visibility); void RegisterExplicitIndexName(const std::string& source_name, const std::string& canonical_name); RawModuleDefV10 BuildModuleDef() const; diff --git a/crates/bindings-cpp/include/spacetimedb/jwt_claims.h b/crates/bindings-cpp/include/spacetimedb/jwt_claims.h index cdc9aef511d..6a72e973633 100644 --- a/crates/bindings-cpp/include/spacetimedb/jwt_claims.h +++ b/crates/bindings-cpp/include/spacetimedb/jwt_claims.h @@ -15,8 +15,8 @@ namespace SpacetimeDB { * This class provides lazy parsing of JWT claims, parsing specific fields * on demand. It follows the same pattern as the Rust and C# implementations. * - * The Identity is provided in the constructor because computing it requires - * Blake3 hashing, which is done on the host side. + * The Identity is the verified sender supplied by the host. Token claims + * cannot override it, including for hosted container credentials. */ class JwtClaims { private: @@ -36,11 +36,10 @@ class JwtClaims { /** * @brief Constructs a JwtClaims from a JWT payload and its associated Identity. * - * The Identity must be provided because computing it requires Blake3 hashing, - * which is performed on the host side. + * The Identity must be the verified sender supplied by the host. * * @param jwt_payload The raw JWT payload (JSON claims) - * @param identity The identity derived from the JWT's issuer and subject + * @param identity The verified sender Identity */ JwtClaims(std::string jwt_payload, Identity identity); @@ -71,8 +70,7 @@ class JwtClaims { /** * @brief Returns the identity for these credentials. * - * The identity is based on the 'iss' and 'sub' claims and is computed - * using Blake3 hashing on the host side. + * This is the verified sender supplied by the host, independently of claims. * * @return The identity */ diff --git a/crates/bindings-cpp/include/spacetimedb/macros.h b/crates/bindings-cpp/include/spacetimedb/macros.h index 2807be4333b..b4ac3ba0d9c 100644 --- a/crates/bindings-cpp/include/spacetimedb/macros.h +++ b/crates/bindings-cpp/include/spacetimedb/macros.h @@ -609,6 +609,15 @@ inline std::vector parseParameterNames(const std::string& param_lis // VISIBILITY FILTER MACRO // ============================================================================= +// Apply to a registered reducer or procedure. Runs after function registration; +// lifecycle reducers only accept Internal. Scheduling preserves this choice. +#define SPACETIMEDB_FUNCTION_VISIBILITY(function_name, visibility) \ + extern "C" __attribute__((export_name("__preinit__40_visibility_" #function_name))) \ + void CONCAT(__spacetimedb_function_visibility_, function_name)() { \ + ::SpacetimeDB::Internal::getV10Builder().SetFunctionVisibility( \ + #function_name, ::SpacetimeDB::FunctionVisibility::visibility); \ + } + /** * @brief Set module case conversion policy using a fixed preinit registration symbol. * @@ -917,4 +926,3 @@ inline std::vector parseParameterNames(const std::string& param_lis #endif // SPACETIMEDB_MACROS_H - diff --git a/crates/bindings-cpp/include/spacetimedb/procedure_context.h b/crates/bindings-cpp/include/spacetimedb/procedure_context.h index 35c93c31475..ee459ab980d 100644 --- a/crates/bindings-cpp/include/spacetimedb/procedure_context.h +++ b/crates/bindings-cpp/include/spacetimedb/procedure_context.h @@ -57,6 +57,7 @@ struct ProcedureContext { private: // Caller's identity - who invoked this procedure Identity sender_; + AuthCtx sender_auth_ = AuthCtx::internal(); public: Environment env; @@ -83,7 +84,11 @@ struct ProcedureContext { ProcedureContext() = default; ProcedureContext(Identity s, Timestamp t, ConnectionId conn_id) - : sender_(s), timestamp(t), connection_id(conn_id) {} + : sender_(s), sender_auth_(AuthCtx::from_connection_id_opt( + conn_id.id.low == 0 && conn_id.id.high == 0 ? std::nullopt : std::optional(conn_id), s)), + timestamp(t), connection_id(conn_id) {} + + const AuthCtx& sender_auth() const { return sender_auth_; } Identity sender() const { return sender_; @@ -99,7 +104,7 @@ struct ProcedureContext { * @code * auto module_id = ctx.database_identity(); * std::string url = "http://localhost:3000/v1/database/" + - * module_id.to_hex() + "/schema?version=9"; + * module_id.to_hex_string() + "/schema?version=10"; * @endcode */ Identity database_identity() const { @@ -198,8 +203,9 @@ struct ProcedureContext { auto make_reducer_ctx = [this](Timestamp tx_timestamp) { return ReducerContext( sender(), - std::optional(connection_id), - tx_timestamp + connection_id.id.low == 0 && connection_id.id.high == 0 ? std::nullopt : std::optional(connection_id), + tx_timestamp, + sender_auth_ ); }; return Internal::with_tx(make_reducer_ctx, body); @@ -230,8 +236,9 @@ struct ProcedureContext { auto make_reducer_ctx = [this](Timestamp tx_timestamp) { return ReducerContext( sender(), - std::optional(connection_id), - tx_timestamp + connection_id.id.low == 0 && connection_id.id.high == 0 ? std::nullopt : std::optional(connection_id), + tx_timestamp, + sender_auth_ ); }; return Internal::try_with_tx(make_reducer_ctx, body); diff --git a/crates/bindings-cpp/src/internal/v10_builder.cpp b/crates/bindings-cpp/src/internal/v10_builder.cpp index a931b79a7c2..3298f19da3d 100644 --- a/crates/bindings-cpp/src/internal/v10_builder.cpp +++ b/crates/bindings-cpp/src/internal/v10_builder.cpp @@ -219,6 +219,31 @@ RawConstraintDefV10 V10Builder::CreateUniqueConstraint(const std::string& table_ }; } +void V10Builder::SetFunctionVisibility(const std::string& name, ::SpacetimeDB::FunctionVisibility visibility) { + FunctionVisibility declared; + switch (visibility) { + case ::SpacetimeDB::FunctionVisibility::Public: declared = FunctionVisibility::ExplicitClientCallable; break; + case ::SpacetimeDB::FunctionVisibility::Private: declared = FunctionVisibility::Private; break; + case ::SpacetimeDB::FunctionVisibility::Internal: declared = FunctionVisibility::Internal; break; + default: + SetConstraintRegistrationError("INVALID_FUNCTION_VISIBILITY", "function='" + name + "'"); + return; + } + for (const auto& lifecycle : lifecycle_reducers_) { + if (lifecycle.function_name == name && declared != FunctionVisibility::Internal) { + SetConstraintRegistrationError("INVALID_LIFECYCLE_VISIBILITY", "function='" + name + "' must be Internal"); + return; + } + } + for (auto& reducer : reducers_) { + if (reducer.source_name == name) { reducer.visibility = declared; return; } + } + for (auto& procedure : procedures_) { + if (procedure.source_name == name) { procedure.visibility = declared; return; } + } + SetConstraintRegistrationError("UNKNOWN_FUNCTION_VISIBILITY", "function='" + name + "' is not a reducer or procedure"); +} + RawModuleDefV10 V10Builder::BuildModuleDef() const { RawModuleDefV10 v10_module; @@ -227,27 +252,12 @@ RawModuleDefV10 V10Builder::BuildModuleDef() const { std::vector reducers = reducers_; std::vector procedures = procedures_; - std::unordered_set internal_functions; - for (const auto& lifecycle : lifecycle_reducers_) { - internal_functions.insert(lifecycle.function_name); - } - for (const auto& schedule : schedules_) { - internal_functions.insert(schedule.function_name); - } - for (auto& reducer : reducers) { - if (internal_functions.find(reducer.source_name) != internal_functions.end()) { - reducer.visibility = FunctionVisibility::Private; - } - } - for (auto& procedure : procedures) { - if (internal_functions.find(procedure.source_name) != internal_functions.end()) { - procedure.visibility = FunctionVisibility::Private; - } - } - RawModuleDefV10Section section_typespace; section_typespace.set<0>(typespace_); v10_module.sections.push_back(section_typespace); + RawModuleDefV10Section capabilities; + capabilities.set<15>(std::vector{"hosted_auth_v1"}); + v10_module.sections.push_back(std::move(capabilities)); if (!types.empty()) { RawModuleDefV10Section section_types; diff --git a/crates/bindings-cpp/tests/unit/CMakeLists.txt b/crates/bindings-cpp/tests/unit/CMakeLists.txt index da7b8705e1c..2a540f96f31 100644 --- a/crates/bindings-cpp/tests/unit/CMakeLists.txt +++ b/crates/bindings-cpp/tests/unit/CMakeLists.txt @@ -11,7 +11,18 @@ endif() add_executable(bindings_cpp_unit_tests main.cpp http_unit_tests.cpp - environment_unit_tests.cpp + hosted_auth_unit_tests.cpp + function_visibility_unit_tests.cpp +) + +# Exercise the real module builder without the standalone WASI shims, which +# replace the Node test runner's standard I/O and process lifecycle functions. +target_sources(bindings_cpp_unit_tests PRIVATE + ../../src/internal/Module.cpp + ../../src/internal/AlgebraicType.cpp + ../../src/internal/v9_builder.cpp + ../../src/internal/v10_builder.cpp + ../../src/internal/module_type_registration.cpp ) target_include_directories(bindings_cpp_unit_tests PRIVATE diff --git a/crates/bindings-cpp/tests/unit/environment_unit_tests.cpp b/crates/bindings-cpp/tests/unit/environment_unit_tests.cpp deleted file mode 100644 index c6bd0f58cd8..00000000000 --- a/crates/bindings-cpp/tests/unit/environment_unit_tests.cpp +++ /dev/null @@ -1,50 +0,0 @@ -#include "test_harness.h" -#include "spacetimedb/environment.h" -#include "spacetimedb/bsatn/reader.h" -#include -#include - -using namespace SpacetimeDB; - -namespace { -size_t payload_offset; -std::string payload; -} - -extern "C" Status env_get(const uint8_t* key, uint32_t key_len, BytesSource* out) { - payload_offset = 0; - const std::string name(reinterpret_cast(key), key_len); - *out = BytesSource{name == "MISSING" ? 0u : 1u}; - return Status{0}; -} - -extern "C" int16_t bytes_source_read(BytesSource, uint8_t* out, size_t* len) { - *len = std::min(*len, payload.size() - payload_offset); - std::memcpy(out, payload.data() + payload_offset, *len); - payload_offset += *len; - return payload_offset == payload.size() ? -1 : 0; -} - -extern "C" void console_log(LogLevel, const uint8_t*, size_t, const uint8_t*, size_t, - uint32_t, const uint8_t*, size_t) {} - -TEST_CASE(environment_preserves_missing_empty_and_all_chunks_without_caching) { - Environment env; - ASSERT_TRUE(!env.get("MISSING").has_value()); - ASSERT_EQ(std::string{}, env.get("EMPTY").value()); - payload = std::string(8192, 'x'); - ASSERT_EQ(payload, env.get("LARGE").value()); - payload = std::string("a\0b", 3); - ASSERT_EQ(payload, env.get("NUL").value()); - payload = "updated"; - ASSERT_EQ(payload, env.get("NUL").value()); -} - -TEST_CASE(optional_reader_matches_canonical_bsatn_tags_and_preserves_following_bytes) { - const std::vector bytes{1, 0, 0, 0, 0, 0, 0, 3, 0, 0, 0, 'a', 0, 'b', 42}; - bsatn::Reader reader(bytes.data(), bytes.size()); - ASSERT_TRUE(!bsatn::deserialize>(reader).has_value()); - ASSERT_EQ(std::string{}, bsatn::deserialize>(reader).value()); - ASSERT_EQ(std::string("a\0b", 3), bsatn::deserialize>(reader).value()); - ASSERT_EQ(uint8_t{42}, reader.read_u8()); -} diff --git a/crates/bindings-cpp/tests/unit/function_visibility_unit_tests.cpp b/crates/bindings-cpp/tests/unit/function_visibility_unit_tests.cpp new file mode 100644 index 00000000000..c9dc94ba3ce --- /dev/null +++ b/crates/bindings-cpp/tests/unit/function_visibility_unit_tests.cpp @@ -0,0 +1,98 @@ +#include "test_harness.h" +#include "spacetimedb/reducer_error.h" +#include "spacetimedb/procedure_context.h" +#include "spacetimedb/internal/v10_builder.h" +#include "spacetimedb/internal/autogen/RawModuleDef.g.h" +#include "spacetimedb/macros.h" + +using namespace SpacetimeDB; +using namespace SpacetimeDB::Internal; + +namespace { +ReducerResult noop(ReducerContext) { return Ok(); } +uint32_t procedure(ProcedureContext) { return 7; } +} + +SPACETIMEDB_FUNCTION_VISIBILITY(visibility_macro_target, Internal); + +TEST_CASE(visibility_macro_applies_after_function_registration) { + auto& builder = getV10Builder(); + builder.RegisterReducer("visibility_macro_target", &noop, {}); + __spacetimedb_function_visibility_visibility_macro_target(); + bool found = false; + for (const auto& section : builder.BuildModuleDef().sections) { + if (section.get_tag() != 3) continue; + for (const auto& reducer : section.get<3>()) { + if (reducer.source_name != "visibility_macro_target") continue; + ASSERT_EQ(SpacetimeDB::Internal::FunctionVisibility::Internal, reducer.visibility); + found = true; + } + } + ASSERT_TRUE(found); +} + +TEST_CASE(v10_retains_explicit_visibility_and_schedule_default) { + V10Builder builder; + builder.RegisterReducer("omitted", &noop, {}); + builder.RegisterReducer("public", &noop, {}); + builder.RegisterReducer("private", &noop, {}); + builder.RegisterReducer("internal", &noop, {}); + builder.SetFunctionVisibility("public", SpacetimeDB::FunctionVisibility::Public); + builder.SetFunctionVisibility("private", SpacetimeDB::FunctionVisibility::Private); + builder.SetFunctionVisibility("internal", SpacetimeDB::FunctionVisibility::Internal); + builder.RegisterSchedule("jobs", 0, "public"); + builder.RegisterSchedule("other_jobs", 0, "omitted"); + builder.RegisterProcedure("procedure", &procedure); + builder.SetFunctionVisibility("procedure", SpacetimeDB::FunctionVisibility::Internal); + + RawModuleDef versioned; + versioned.set<2>(builder.BuildModuleDef()); + std::vector bytes; + bsatn::Writer writer(bytes); + bsatn::serialize(writer, versioned); + ASSERT_EQ(uint8_t{2}, bytes.at(0)); + ASSERT_EQ(uint8_t{2}, versioned.get_tag()); + bool saw_reducers = false, saw_procedure = false, saw_capability = false; + for (const auto& section : versioned.get<2>().sections) { + if (section.get_tag() == 3) { + const auto& reducers = section.get<3>(); + ASSERT_EQ(size_t{4}, reducers.size()); + ASSERT_EQ(SpacetimeDB::Internal::FunctionVisibility::ClientCallable, reducers[0].visibility); + ASSERT_EQ(SpacetimeDB::Internal::FunctionVisibility::ExplicitClientCallable, reducers[1].visibility); + ASSERT_EQ(SpacetimeDB::Internal::FunctionVisibility::Private, reducers[2].visibility); + ASSERT_EQ(SpacetimeDB::Internal::FunctionVisibility::Internal, reducers[3].visibility); + saw_reducers = true; + } else if (section.get_tag() == 4) { + ASSERT_EQ(SpacetimeDB::Internal::FunctionVisibility::Internal, section.get<4>().at(0).visibility); + saw_procedure = true; + } else if (section.get_tag() == 15) { + ASSERT_EQ(std::vector{"hosted_auth_v1"}, section.get<15>()); + saw_capability = true; + } + } + ASSERT_TRUE(saw_reducers && saw_procedure && saw_capability); +} + +TEST_CASE(v10_visibility_extends_enum_without_changing_reducer_field_layout) { + V10Builder builder; + builder.RegisterReducer("r", &noop, {}); + auto reducer = builder.GetReducers().at(0); + for (uint8_t tag = 0; tag <= 3; ++tag) { + reducer.visibility = static_cast(tag); + std::vector bytes; + bsatn::Writer writer(bytes); + bsatn::serialize(writer, reducer); + const std::vector expected{1, 0, 0, 0, 'r', 0, 0, 0, 0, tag, 2, 0, 0, 0, 0, 4}; + ASSERT_EQ(expected, bytes); + const RawProcedureDefV10 procedure_def{ + "p", ProductType{}, reducer.ok_return_type, reducer.visibility, + }; + std::vector procedure_bytes; + bsatn::Writer procedure_writer(procedure_bytes); + bsatn::serialize(procedure_writer, procedure_def); + const std::vector expected_procedure{ + 1, 0, 0, 0, 'p', 0, 0, 0, 0, 2, 0, 0, 0, 0, tag, + }; + ASSERT_EQ(expected_procedure, procedure_bytes); + } +} diff --git a/crates/bindings-cpp/tests/unit/hosted_auth_unit_tests.cpp b/crates/bindings-cpp/tests/unit/hosted_auth_unit_tests.cpp new file mode 100644 index 00000000000..cfa432bf30b --- /dev/null +++ b/crates/bindings-cpp/tests/unit/hosted_auth_unit_tests.cpp @@ -0,0 +1,137 @@ +#include "test_harness.h" +#include "spacetimedb/procedure_context.h" + +#include +#include + +using namespace SpacetimeDB; + +namespace { +uint32_t auth_flags; +size_t flag_reads; +size_t jwt_reads; +size_t payload_offset; +std::string jwt_payload; + +Identity verified_sender() { + std::array bytes{}; + bytes[0] = 42; + return Identity(bytes); +} + +void reset_host(uint32_t flags, std::string payload = {}) { + auth_flags = flags; + flag_reads = jwt_reads = payload_offset = 0; + jwt_payload = std::move(payload); +} +} + +extern "C" uint32_t get_call_auth_flags() { + ++flag_reads; + return auth_flags; +} + +extern "C" Status get_jwt(const uint8_t*, BytesSource* out) { + ++jwt_reads; + payload_offset = 0; + *out = BytesSource{jwt_payload.empty() ? 0u : 1u}; + return Status{0}; +} + +extern "C" Status env_get(const uint8_t* key, uint32_t key_len, BytesSource* out) { + payload_offset = 0; + const std::string name(reinterpret_cast(key), key_len); + if (name == "ERROR") return Status{1}; + *out = BytesSource{name == "MISSING" ? 0u : 1u}; + return Status{0}; +} + +extern "C" int16_t bytes_source_read(BytesSource, uint8_t* out, size_t* len) { + *len = std::min(*len, jwt_payload.size() - payload_offset); + std::memcpy(out, jwt_payload.data() + payload_offset, *len); + payload_offset += *len; + // Successful exhaustion can return the last bytes together with -1. + return payload_offset == jwt_payload.size() ? -1 : 0; +} + +extern "C" void identity(uint8_t* out) { std::memset(out, 0, 32); } +extern "C" Status procedure_start_mut_tx(int64_t* out) { *out = 0; return Status{0}; } +extern "C" Status procedure_commit_mut_tx() { return Status{0}; } +extern "C" Status procedure_abort_mut_tx() { return Status{0}; } +extern "C" void console_log(LogLevel, const uint8_t*, size_t, const uint8_t*, size_t, + uint32_t, const uint8_t*, size_t) {} + +TEST_CASE(authority_without_connection_is_captured_from_host) { + for (uint32_t flags : {0u, 1u}) { + reset_host(flags); + auto ctx = AuthCtx::from_connection_id_opt(std::nullopt, verified_sender()); + auth_flags = flags ^ 1; + ASSERT_EQ(size_t{1}, flag_reads); + ASSERT_EQ(flags == 1, ctx.is_internal()); + ASSERT_TRUE(!ctx.has_jwt()); + ASSERT_EQ(verified_sender(), ctx.get_caller_identity()); + ASSERT_EQ(size_t{0}, jwt_reads); + } +} + +TEST_CASE(internal_call_retains_lazy_jwt_and_verified_identity) { + reset_host(1, R"({"iss":"other","sub":"other","identity":"untrusted"})"); + auto ctx = AuthCtx::from_connection_id(ConnectionId(5), verified_sender()); + auth_flags = 0; + ASSERT_TRUE(ctx.is_internal()); + ASSERT_EQ(size_t{0}, jwt_reads); + ASSERT_TRUE(ctx.has_jwt()); + ASSERT_EQ(verified_sender(), ctx.get_jwt()->get_identity()); + ASSERT_EQ(std::string("other"), ctx.get_jwt()->subject()); + ASSERT_EQ(size_t{1}, jwt_reads); +} + +TEST_CASE(jwt_source_reads_all_chunks_including_final_exhausted_bytes) { + reset_host(0, "{\"padding\":\"" + std::string(8192, 'x') + "\",\"sub\":\"last\"}"); + auto ctx = AuthCtx::from_connection_id(ConnectionId(5), verified_sender()); + ASSERT_TRUE(ctx.has_jwt()); + ASSERT_EQ(std::string("last"), ctx.get_jwt()->subject()); + ASSERT_EQ(jwt_payload.size(), payload_offset); + ASSERT_EQ(size_t{1}, jwt_reads); +} + +TEST_CASE(procedure_transactions_preserve_authority_connection_and_sender) { + for (uint64_t connection : {0u, 5u}) { + reset_host(1, R"({"sub":"worker"})"); + ProcedureContext ctx(verified_sender(), Timestamp::from_micros_since_epoch(0), ConnectionId(connection)); + auth_flags = 0; + ctx.with_tx([&](TxContext& tx) { + ASSERT_TRUE(tx.sender_auth().is_internal()); + ASSERT_EQ(verified_sender(), tx.sender()); + ASSERT_EQ(connection != 0, tx.connection_id.has_value()); + ASSERT_EQ(connection != 0, tx.sender_auth().has_jwt()); + if (connection) ASSERT_EQ(verified_sender(), tx.sender_auth().get_jwt()->get_identity()); + }); + ASSERT_EQ(size_t{1}, flag_reads); + } +} + +TEST_CASE(environment_preserves_missing_empty_and_all_chunks_without_caching) { + Environment env; + reset_host(0); + ASSERT_TRUE(!env.get("MISSING").has_value()); + ASSERT_EQ(std::string{}, env.get("EMPTY").value()); + jwt_payload = std::string(8192, 'x'); + ASSERT_EQ(jwt_payload, env.get("LARGE").value()); + jwt_payload = std::string("a\0b", 3); + ASSERT_EQ(jwt_payload, env.get("NUL").value()); + jwt_payload = "updated"; + ASSERT_EQ(jwt_payload, env.get("NUL").value()); + ProcedureContext procedure(verified_sender(), Timestamp::from_micros_since_epoch(0), ConnectionId(0)); + ASSERT_EQ(jwt_payload, procedure.env.get("VALUE").value()); + procedure.with_tx([&](TxContext& tx) { ASSERT_EQ(jwt_payload, tx.env.get("VALUE").value()); }); +} + +TEST_CASE(optional_reader_matches_canonical_bsatn_tags_and_preserves_following_bytes) { + const std::vector bytes{1, 0, 0, 0, 0, 0, 0, 3, 0, 0, 0, 'a', 0, 'b', 42}; + bsatn::Reader reader(bytes.data(), bytes.size()); + ASSERT_TRUE(!bsatn::deserialize>(reader).has_value()); + ASSERT_EQ(std::string{}, bsatn::deserialize>(reader).value()); + ASSERT_EQ(std::string("a\0b", 3), bsatn::deserialize>(reader).value()); + ASSERT_EQ(uint8_t{42}, reader.read_u8()); +} diff --git a/crates/bindings-csharp/Codegen.Tests/Tests.cs b/crates/bindings-csharp/Codegen.Tests/Tests.cs index 4133819ef9c..6f0a33fa0e3 100644 --- a/crates/bindings-csharp/Codegen.Tests/Tests.cs +++ b/crates/bindings-csharp/Codegen.Tests/Tests.cs @@ -347,6 +347,65 @@ public static void @params(ProcedureContext ctx) Assert.Empty(GetCompilationErrors(compilationAfterGen)); } + [Fact] + public static async Task ExplicitFunctionVisibilityCompilesAndRejectsExternalLifecycle() + { + var fixture = await Fixture.Compile("server"); + const string source = """ + using SpacetimeDB; + public static partial class VisibilityFunctions + { + [Reducer(Visibility = FunctionVisibility.Public)] + public static void PublicJob(ReducerContext ctx) {} + [Reducer(Visibility = FunctionVisibility.Private)] + public static void PrivateJob(ReducerContext ctx) {} + [Reducer(Visibility = FunctionVisibility.Internal)] + public static void InternalJob(ReducerContext ctx) {} + [Procedure(Visibility = FunctionVisibility.Internal)] + public static int InternalProcedure(ProcedureContext ctx) => 1; + } + """; + var parseOptions = new CSharpParseOptions(fixture.SampleCompilation.LanguageVersion); + var tree = CSharpSyntaxTree.ParseText(source, parseOptions); + var compilation = fixture.SampleCompilation.AddSyntaxTrees(tree); + var driver = CSharpGeneratorDriver.Create( + [ + new SpacetimeDB.Codegen.Type().AsSourceGenerator(), + new SpacetimeDB.Codegen.Module().AsSourceGenerator(), + ], + parseOptions: parseOptions + ); + var result = driver.RunGenerators(compilation).GetRunResult(); + Assert.Empty(result.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error)); + Assert.Empty(GetCompilationErrors(compilation.AddSyntaxTrees(result.GeneratedTrees))); + var generated = string.Join("\n", result.GeneratedTrees.Select(t => t.ToString())); + Assert.Contains( + "Visibility: SpacetimeDB.Internal.FunctionVisibility.ExplicitClientCallable", + generated + ); + Assert.Contains("Visibility: SpacetimeDB.Internal.FunctionVisibility.Private", generated); + Assert.Contains("Visibility: SpacetimeDB.Internal.FunctionVisibility.Internal", generated); + + var invalid = CSharpSyntaxTree.ParseText( + """ + using SpacetimeDB; + public static partial class BadVisibility + { + [Reducer(ReducerKind.Init, Visibility = FunctionVisibility.Public)] + public static void InvalidLifecycle(ReducerContext ctx) {} + } + """, + parseOptions + ); + var rejected = driver + .RunGenerators(fixture.SampleCompilation.AddSyntaxTrees(invalid)) + .GetRunResult(); + Assert.Contains( + rejected.Diagnostics, + diagnostic => diagnostic.GetMessage().Contains("Lifecycle reducers only permit") + ); + } + [Fact] public static async Task TestDiagnostics() { diff --git a/crates/bindings-csharp/Codegen.Tests/fixtures/diag/snapshots/Module#FFI.verified.cs b/crates/bindings-csharp/Codegen.Tests/fixtures/diag/snapshots/Module#FFI.verified.cs index 2eb70c4a352..12863525777 100644 --- a/crates/bindings-csharp/Codegen.Tests/fixtures/diag/snapshots/Module#FFI.verified.cs +++ b/crates/bindings-csharp/Codegen.Tests/fixtures/diag/snapshots/Module#FFI.verified.cs @@ -3298,7 +3298,7 @@ SpacetimeDB.BSATN.ITypeRegistrar registrar new( SourceName: nameof(TestDuplicateReducerKind1), Params: [], - Visibility: SpacetimeDB.Internal.FunctionVisibility.ClientCallable, + Visibility: SpacetimeDB.Internal.FunctionVisibility.Internal, OkReturnType: SpacetimeDB.BSATN.AlgebraicType.Unit, ErrReturnType: new SpacetimeDB.BSATN.AlgebraicType.String(default) ); @@ -3319,7 +3319,7 @@ SpacetimeDB.BSATN.ITypeRegistrar registrar new( SourceName: nameof(TestDuplicateReducerKind2), Params: [], - Visibility: SpacetimeDB.Internal.FunctionVisibility.ClientCallable, + Visibility: SpacetimeDB.Internal.FunctionVisibility.Internal, OkReturnType: SpacetimeDB.BSATN.AlgebraicType.Unit, ErrReturnType: new SpacetimeDB.BSATN.AlgebraicType.String(default) ); diff --git a/crates/bindings-csharp/Codegen.Tests/fixtures/server/snapshots/Module#FFI.verified.cs b/crates/bindings-csharp/Codegen.Tests/fixtures/server/snapshots/Module#FFI.verified.cs index 7f196c87a30..17ddaac9371 100644 --- a/crates/bindings-csharp/Codegen.Tests/fixtures/server/snapshots/Module#FFI.verified.cs +++ b/crates/bindings-csharp/Codegen.Tests/fixtures/server/snapshots/Module#FFI.verified.cs @@ -2342,7 +2342,7 @@ SpacetimeDB.BSATN.ITypeRegistrar registrar new( SourceName: nameof(Init), Params: [], - Visibility: SpacetimeDB.Internal.FunctionVisibility.ClientCallable, + Visibility: SpacetimeDB.Internal.FunctionVisibility.Internal, OkReturnType: SpacetimeDB.BSATN.AlgebraicType.Unit, ErrReturnType: new SpacetimeDB.BSATN.AlgebraicType.String(default) ); diff --git a/crates/bindings-csharp/Codegen/Diag.cs b/crates/bindings-csharp/Codegen/Diag.cs index c2374db5b8e..5d6f3259d56 100644 --- a/crates/bindings-csharp/Codegen/Diag.cs +++ b/crates/bindings-csharp/Codegen/Diag.cs @@ -361,4 +361,13 @@ string type $"View '{ctx.method.Identifier}' declares primary key '{ctx.primaryKey}', but its type '{ctx.type}' is not supported for view primary keys.", ctx => ctx.primaryKeySyntax ); + + public static readonly ErrorDescriptor InvalidFunctionVisibility = + new( + group, + "Invalid function visibility", + _ => + $"Visibility must be Default, Public, Private, or Internal. Lifecycle reducers only permit Default or Internal.", + method => method.Identifier + ); } diff --git a/crates/bindings-csharp/Codegen/Module.cs b/crates/bindings-csharp/Codegen/Module.cs index e3f146ad3f2..daec6521008 100644 --- a/crates/bindings-csharp/Codegen/Module.cs +++ b/crates/bindings-csharp/Codegen/Module.cs @@ -1500,13 +1500,46 @@ public static byte[] Invoke( } /// -/// Represents a reducer method declaration in a module. +/// Validates a declared function visibility and maps it to the V10 schema. /// +static class FunctionVisibilityDeclaration +{ + internal static string Resolve( + FunctionVisibility visibility, + bool lifecycle, + MethodDeclarationSyntax method, + DiagReporter diag + ) + { + if ( + ( + lifecycle + && visibility is not (FunctionVisibility.Default or FunctionVisibility.Internal) + ) || !Enum.IsDefined(typeof(FunctionVisibility), visibility) + ) + { + diag.Report(ErrorDescriptor.InvalidFunctionVisibility, method); + return "SpacetimeDB.Internal.FunctionVisibility.Internal"; + } + return visibility switch + { + FunctionVisibility.Public => + "SpacetimeDB.Internal.FunctionVisibility.ExplicitClientCallable", + FunctionVisibility.Private => "SpacetimeDB.Internal.FunctionVisibility.Private", + FunctionVisibility.Internal => "SpacetimeDB.Internal.FunctionVisibility.Internal", + _ => lifecycle + ? "SpacetimeDB.Internal.FunctionVisibility.Internal" + : "SpacetimeDB.Internal.FunctionVisibility.ClientCallable", + }; + } +} + record ReducerDeclaration { public readonly string Name; public readonly string? CanonicalName; public readonly ReducerKind Kind; + public readonly string Visibility; public readonly string FullName; public readonly EquatableArray Args; public readonly Scope Scope; @@ -1545,6 +1578,12 @@ public ReducerDeclaration(GeneratorAttributeSyntaxContext context, DiagReporter } Kind = attr.Kind; + Visibility = FunctionVisibilityDeclaration.Resolve( + attr.Visibility, + Kind != ReducerKind.UserDefined, + methodSyntax, + diag + ); CanonicalName = attr.Name; FullName = SymbolToName(method); Args = new( @@ -1573,7 +1612,7 @@ sealed class {{Identifier}}: SpacetimeDB.Internal.IReducer { public SpacetimeDB.Internal.RawReducerDefV10 MakeReducerDef(SpacetimeDB.BSATN.ITypeRegistrar registrar) => new ( SourceName: nameof({{Identifier}}), Params: [{{MemberDeclaration.GenerateDefs(Args)}}], - Visibility: SpacetimeDB.Internal.FunctionVisibility.ClientCallable, + Visibility: {{Visibility}}, OkReturnType: SpacetimeDB.BSATN.AlgebraicType.Unit, ErrReturnType: new SpacetimeDB.BSATN.AlgebraicType.String(default) ); @@ -1630,6 +1669,7 @@ record ProcedureDeclaration { public readonly string Name; public readonly string? CanonicalName; + public readonly string Visibility; public readonly string FullName; public readonly EquatableArray Args; public readonly Scope Scope; @@ -1646,6 +1686,12 @@ public ProcedureDeclaration(GeneratorAttributeSyntaxContext context, DiagReporte var methodSyntax = (MethodDeclarationSyntax)context.TargetNode; var method = (IMethodSymbol)context.TargetSymbol; var attr = context.Attributes.Single().ParseAs(); + Visibility = FunctionVisibilityDeclaration.Resolve( + attr.Visibility, + false, + methodSyntax, + diag + ); if ( method.Parameters.FirstOrDefault()?.Type @@ -1804,7 +1850,7 @@ sealed class {{{Identifier}}} : SpacetimeDB.Internal.IProcedure { SourceName: nameof({{{Identifier}}}), Params: [{{{MemberDeclaration.GenerateDefs(Args)}}}], ReturnType: {{{returnTypeExpr}}}, - Visibility: SpacetimeDB.Internal.FunctionVisibility.ClientCallable + Visibility: {{{Visibility}}} ); public static byte[] Invoke(BinaryReader reader, SpacetimeDB.Internal.IProcedureContext ctx) { diff --git a/crates/bindings-csharp/README.md b/crates/bindings-csharp/README.md index 289bd570ff0..94158b66fb5 100644 --- a/crates/bindings-csharp/README.md +++ b/crates/bindings-csharp/README.md @@ -6,6 +6,27 @@ See the [C# module library reference](https://spacetimedb.com/docs/modules/c-sha ## Internal documentation +### Function visibility and invocation authentication + +Reducers and procedures can declare `Visibility = FunctionVisibility.Public`, +`Private`, or `Internal` in their attributes. Omission (`Default`) means public +for ordinary functions and private for scheduled functions. An explicit choice +is preserved when the function is scheduled. Lifecycle reducers permit only +omission or `Internal` and can only run for their host lifecycle event. + +Internal functions require verified internal authority. Private functions also +admit the owner, and public functions admit any client. For example: + +```csharp +[Reducer(Visibility = FunctionVisibility.Internal)] +public static void ProcessJobs(ReducerContext ctx) { } +``` + +`ctx.SenderAuth.IsInternal` comes from the host's invocation authority. It is +independent of connection and JWT presence, so an internal call can have a JWT. +JWT identity is the verified sender supplied by the host. Newly compiled modules +emit schema V10 and advertise `hosted_auth_v1`, requiring a compatible host. + These projects contain the SpacetimeDB SATS typesystem, codegen and runtime bindings for SpacetimeDB WebAssembly modules. It also contains serialization code for SpacetimeDB C# clients. @@ -19,4 +40,3 @@ The [`Codegen`](./Codegen/) and [`Runtime`](./Runtime/) libraries are used: - only by C# Modules. They provide all of the functionality needed to write SpacetimeDB modules in C#. See their READMEs for more information. - diff --git a/crates/bindings-csharp/Runtime.Tests/FunctionVisibilityTests.cs b/crates/bindings-csharp/Runtime.Tests/FunctionVisibilityTests.cs new file mode 100644 index 00000000000..d50e6cff7f4 --- /dev/null +++ b/crates/bindings-csharp/Runtime.Tests/FunctionVisibilityTests.cs @@ -0,0 +1,68 @@ +namespace Runtime.Tests; + +using SpacetimeDB.BSATN; +using SpacetimeDB.Internal; + +public class FunctionVisibilityTests +{ + [Theory] + [InlineData(FunctionVisibility.Private, 0)] + [InlineData(FunctionVisibility.ClientCallable, 1)] + [InlineData(FunctionVisibility.Internal, 2)] + [InlineData(FunctionVisibility.ExplicitClientCallable, 3)] + public void V10RetainsVisibilityEnumEncoding(FunctionVisibility visibility, byte tag) + { + var bytes = IStructuralReadWrite.ToBytes( + new SpacetimeDB.BSATN.Enum(), + visibility + ); + Assert.Equal(new byte[] { tag }, bytes); + } + + [Theory] + [InlineData(FunctionVisibility.ExplicitClientCallable)] + [InlineData(FunctionVisibility.ClientCallable)] + [InlineData(FunctionVisibility.Private)] + [InlineData(FunctionVisibility.Internal)] + public void SchedulingPreservesVisibility(FunctionVisibility visibility) + { + var module = new RawModuleDefV10(); + var reducer = new RawReducerDefV10( + "run_job", + [], + visibility, + AlgebraicType.Unit, + new AlgebraicType.String(default) + ); + module.RegisterReducer(reducer, null); + module.RegisterTable( + new RawTableDefV10 { SourceName = "jobs" }, + new RawScheduleDefV10(null, "jobs", 0, "run_job") + ); + var raw = module.BuildModuleDefinition(); + var reducers = Assert.Single(raw.Sections.OfType()); + Assert.Equal(visibility, Assert.Single(reducers.Reducers_).Visibility); + var capabilities = Assert.Single( + raw.Sections.OfType() + ); + Assert.Contains("hosted_auth_v1", capabilities.Capabilities_); + } + + [Theory] + [InlineData(FunctionVisibility.ClientCallable)] + [InlineData(FunctionVisibility.ExplicitClientCallable)] + public void LifecycleRejectsExternalVisibility(FunctionVisibility visibility) + { + var module = new RawModuleDefV10(); + var reducer = new RawReducerDefV10( + "initialize", + [], + visibility, + AlgebraicType.Unit, + new AlgebraicType.String(default) + ); + Assert.Throws( + () => module.RegisterReducer(reducer, Lifecycle.Init) + ); + } +} diff --git a/crates/bindings-csharp/Runtime.Tests/HostedAuthTests.cs b/crates/bindings-csharp/Runtime.Tests/HostedAuthTests.cs new file mode 100644 index 00000000000..ada932fb631 --- /dev/null +++ b/crates/bindings-csharp/Runtime.Tests/HostedAuthTests.cs @@ -0,0 +1,43 @@ +namespace Runtime.Tests; + +using SpacetimeDB; + +public class HostedAuthTests +{ + [Theory] + [InlineData(0u, false)] + [InlineData(1u, true)] + public void NoJwtCallsPreserveVerifiedInternalFlag(uint flags, bool expectedInternal) + { + var auth = AuthCtx.FromVerifiedCall(flags, () => null); + Assert.Equal(expectedInternal, auth.IsInternal); + Assert.False(auth.HasJwt); + Assert.Null(auth.Jwt); + } + + [Fact] + public void InternalCallCanRetainJwtAndVerifiedSenderIdentity() + { + var sender = Identity.FromHexString(new string('a', 64)); + var reads = 0; + var flags = 1u; + var auth = AuthCtx.FromVerifiedCall( + flags, + () => + { + reads++; + return new JwtClaims( + "{\"iss\":\"different-issuer\",\"sub\":\"different-subject\",\"identity\":\"untrusted\"}", + sender + ); + } + ); + flags = 0; + Assert.True(auth.IsInternal); + Assert.Equal(0, reads); + Assert.True(auth.HasJwt); + Assert.Equal(sender, auth.Jwt!.Identity); + Assert.Equal("different-subject", auth.Jwt.Subject); + Assert.Equal(1, reads); + } +} diff --git a/crates/bindings-csharp/Runtime/Attrs.cs b/crates/bindings-csharp/Runtime/Attrs.cs index 3865f925a2a..d214960d4fa 100644 --- a/crates/bindings-csharp/Runtime/Attrs.cs +++ b/crates/bindings-csharp/Runtime/Attrs.cs @@ -199,18 +199,30 @@ public enum ReducerKind ClientDisconnected, } + /// Invocation admission for reducers and procedures. + public enum FunctionVisibility + { + /// Public for ordinary functions, Private for scheduled functions. + Default, + Public, + Private, + Internal, + } + [AttributeUsage(AttributeTargets.Method, Inherited = false)] public sealed class ReducerAttribute(ReducerKind kind = ReducerKind.UserDefined) : Attribute { public ReducerKind Kind => kind; public string? Name { get; init; } + public FunctionVisibility Visibility { get; init; } } [AttributeUsage(AttributeTargets.Method, Inherited = false)] public sealed class ProcedureAttribute() : Attribute { public string? Name { get; init; } + public FunctionVisibility Visibility { get; init; } } [AttributeUsage(AttributeTargets.Method, Inherited = false)] diff --git a/crates/bindings-csharp/Runtime/AuthCtx.cs b/crates/bindings-csharp/Runtime/AuthCtx.cs index c7fcdea47e0..ff34468ad76 100644 --- a/crates/bindings-csharp/Runtime/AuthCtx.cs +++ b/crates/bindings-csharp/Runtime/AuthCtx.cs @@ -16,12 +16,10 @@ private AuthCtx(bool isInternal, Func jwtFactory) } /// - /// Create an AuthCtx for an internal call, with no JWT. + /// Capture verified invocation authority independently from lazy JWT loading. /// - private static AuthCtx Internal() - { - return new AuthCtx(isInternal: true, jwtFactory: () => null); - } + internal static AuthCtx FromVerifiedCall(uint callAuthFlags, Func jwtFactory) => + new(isInternal: (callAuthFlags & 1) != 0, jwtFactory); /// /// Create an AuthCtx by looking up the credentials for a connection id in system tables. @@ -31,20 +29,27 @@ private static AuthCtx Internal() /// public static AuthCtx BuildFromSystemTables(ConnectionId? connectionId, Identity identity) { + // Read synchronously while this invocation is active. Neither connection + // presence nor token claims determine internal authority. + var callAuthFlags = SpacetimeDB.Internal.FFI.get_call_auth_flags(); if (connectionId == null) { - return Internal(); + return FromVerifiedCall(callAuthFlags, () => null); } - return FromConnectionId(connectionId.Value, identity); + return FromConnectionId(connectionId.Value, identity, callAuthFlags); } /// /// Create an AuthCtx that reads JWT for a given connection ID. /// - private static AuthCtx FromConnectionId(ConnectionId connectionId, Identity identity) + private static AuthCtx FromConnectionId( + ConnectionId connectionId, + Identity identity, + uint callAuthFlags + ) { - return new AuthCtx( - isInternal: false, + return FromVerifiedCall( + callAuthFlags, jwtFactory: () => { var result = SpacetimeDB.Internal.FFI.get_jwt(ref connectionId, out var source); @@ -65,23 +70,18 @@ private static AuthCtx FromConnectionId(ConnectionId connectionId, Identity iden } /// - /// True if this reducer was spawned from inside the database. + /// True if the host verified internal authority for this invocation. /// public bool IsInternal => _isInternal; /// /// Check if there is a JWT present. - /// If IsInternal is true, this will be false. + /// Independent of IsInternal. An internal call may also have a JWT. /// public bool HasJwt { get { - if (_isInternal) - { - return false; - } - // At this point we do load the bytes. return _jwtLazy.Value != null; } diff --git a/crates/bindings-csharp/Runtime/Internal/Autogen/FunctionVisibility.g.cs b/crates/bindings-csharp/Runtime/Internal/Autogen/FunctionVisibility.g.cs index 2f9772dd591..29adc856f78 100644 --- a/crates/bindings-csharp/Runtime/Internal/Autogen/FunctionVisibility.g.cs +++ b/crates/bindings-csharp/Runtime/Internal/Autogen/FunctionVisibility.g.cs @@ -12,5 +12,7 @@ public enum FunctionVisibility { Private, ClientCallable, + Internal, + ExplicitClientCallable, } } diff --git a/crates/bindings-csharp/Runtime/Internal/Autogen/RawModuleDefV10Section.g.cs b/crates/bindings-csharp/Runtime/Internal/Autogen/RawModuleDefV10Section.g.cs index 61212c98e89..21f96921e5a 100644 --- a/crates/bindings-csharp/Runtime/Internal/Autogen/RawModuleDefV10Section.g.cs +++ b/crates/bindings-csharp/Runtime/Internal/Autogen/RawModuleDefV10Section.g.cs @@ -23,6 +23,7 @@ public partial record RawModuleDefV10Section : SpacetimeDB.TaggedEnum<( System.Collections.Generic.List HttpHandlers, System.Collections.Generic.List HttpRoutes, System.Collections.Generic.List ViewPrimaryKeys, - System.Collections.Generic.List Submodules + System.Collections.Generic.List Submodules, + System.Collections.Generic.List Capabilities )>; } diff --git a/crates/bindings-csharp/Runtime/Internal/FFI.cs b/crates/bindings-csharp/Runtime/Internal/FFI.cs index f498e6ed8ea..8b2ecca8206 100644 --- a/crates/bindings-csharp/Runtime/Internal/FFI.cs +++ b/crates/bindings-csharp/Runtime/Internal/FFI.cs @@ -109,6 +109,14 @@ internal static partial class FFI #endif ; + const string StdbNamespace10_6 = +#if EXPERIMENTAL_WASM_AOT + "spacetime_10.6" +#else + "bindings" +#endif + ; + const string StdbNamespace10_7 = #if EXPERIMENTAL_WASM_AOT "spacetime_10.7" @@ -123,6 +131,9 @@ public static unsafe partial CheckedStatus env_get( uint keyLen, out BytesSource source ); + [LibraryImport(StdbNamespace10_6)] + public static partial uint get_call_auth_flags(); + [NativeMarshalling(typeof(Marshaller))] public struct CheckedStatus diff --git a/crates/bindings-csharp/Runtime/Internal/Module.cs b/crates/bindings-csharp/Runtime/Internal/Module.cs index 177fddd785a..99b64aa0725 100644 --- a/crates/bindings-csharp/Runtime/Internal/Module.cs +++ b/crates/bindings-csharp/Runtime/Internal/Module.cs @@ -56,13 +56,23 @@ internal AlgebraicType.Ref RegisterType(Func l.FunctionName) - .Concat(scheduleDefs.Select(s => s.FunctionName)) - .ToHashSet(StringComparer.Ordinal); - - foreach (var reducer in reducerDefs) - { - if (internalFunctions.Contains(reducer.SourceName)) - { - reducer.Visibility = FunctionVisibility.Private; - } - } - - foreach (var procedure in procedureDefs) - { - if (internalFunctions.Contains(procedure.SourceName)) - { - procedure.Visibility = FunctionVisibility.Private; - } - } - var sections = new List { new RawModuleDefV10Section.Typespace(typespace), + new RawModuleDefV10Section.Capabilities(["hosted_auth_v1"]), }; if (typeDefs.Count > 0) diff --git a/crates/bindings-csharp/Runtime/JwtClaims.cs b/crates/bindings-csharp/Runtime/JwtClaims.cs index 3ca3e11e029..bbd53cdfd25 100644 --- a/crates/bindings-csharp/Runtime/JwtClaims.cs +++ b/crates/bindings-csharp/Runtime/JwtClaims.cs @@ -16,8 +16,8 @@ public sealed class JwtClaims /// /// Create a JwtClaims from a raw JWT payload (JSON claims) and its associated Identity. /// - /// This only takes an Identity because the Blake3 hash package on nuget wraps rust code. - /// We should not expose this constructor publicly, but it is needed for AuthCtx. + /// Identity is the verified sender provided by the host. Claims cannot + /// override it, including for hosted database credentials. /// internal JwtClaims(string jwt, Identity identity) { diff --git a/crates/bindings-csharp/Runtime/Runtime.csproj b/crates/bindings-csharp/Runtime/Runtime.csproj index 14b2356594e..19e6405b2ec 100644 --- a/crates/bindings-csharp/Runtime/Runtime.csproj +++ b/crates/bindings-csharp/Runtime/Runtime.csproj @@ -52,6 +52,7 @@ + diff --git a/crates/bindings-csharp/Runtime/bindings.c b/crates/bindings-csharp/Runtime/bindings.c index f4d3635a5f2..64bb4497d4a 100644 --- a/crates/bindings-csharp/Runtime/bindings.c +++ b/crates/bindings-csharp/Runtime/bindings.c @@ -134,6 +134,10 @@ IMPORT(Status, datastore_clear, (TableId table_id, uint64_t* count), (table_id, count)); #undef SPACETIME_MODULE_VERSION + +#define SPACETIME_MODULE_VERSION "spacetime_10.6" +IMPORT(uint32_t, get_call_auth_flags, (void), ()); +#undef SPACETIME_MODULE_VERSION #define SPACETIME_MODULE_VERSION "spacetime_10.7" IMPORT(Status, env_get, (const uint8_t* key, uint32_t key_len, BytesSource* source), (key, key_len, source)); diff --git a/crates/bindings-macro/src/procedure.rs b/crates/bindings-macro/src/procedure.rs index 9f76e5b547f..129b32cc2fe 100644 --- a/crates/bindings-macro/src/procedure.rs +++ b/crates/bindings-macro/src/procedure.rs @@ -1,4 +1,5 @@ use crate::reducer::{assert_only_lifetime_generics, extract_typed_args, generate_explicit_names_impl}; +use crate::reducer::{parse_visibility, DeclaredVisibility}; use crate::sym; use crate::util::{check_duplicate, ident_to_litstr, match_meta}; use proc_macro2::TokenStream; @@ -10,12 +11,16 @@ use syn::{ItemFn, LitStr}; pub(crate) struct ProcedureArgs { /// For consistency with reducers: allow specifying a different export name than the Rust function name. name: Option, + visibility: Option, } impl ProcedureArgs { pub(crate) fn parse(input: TokenStream) -> syn::Result { let mut args = Self::default(); syn::meta::parser(|meta| { + if parse_visibility(&meta, &mut args.visibility)? { + return Ok(()); + } match_meta!(match meta { sym::name => { check_duplicate(&args.name, &meta)?; @@ -29,10 +34,11 @@ impl ProcedureArgs { } } -pub(crate) fn procedure_impl(_args: ProcedureArgs, original_function: &ItemFn) -> syn::Result { +pub(crate) fn procedure_impl(args: ProcedureArgs, original_function: &ItemFn) -> syn::Result { let func_name = &original_function.sig.ident; let vis = &original_function.vis; - let explicit_name = _args.name.as_ref(); + let explicit_name = args.name.as_ref(); + let visibility = args.visibility.map(DeclaredVisibility::tokens).into_iter(); let procedure_name = ident_to_litstr(func_name); @@ -117,6 +123,7 @@ pub(crate) fn procedure_impl(_args: ProcedureArgs, original_function: &ItemFn) - /// The name of this function const NAME: &'static str = #procedure_name; + #(const DECLARED_VISIBILITY: Option = Some(#visibility);)* /// The parameter names of this function const ARG_NAMES: &'static [Option<&'static str>] = &[#(#opt_arg_names),*]; @@ -133,3 +140,29 @@ pub(crate) fn procedure_impl(_args: ProcedureArgs, original_function: &ItemFn) - #generate_explicit_names }) } + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn procedure_visibility_rejects_duplicates_and_emits_selection() { + assert!(ProcedureArgs::parse(quote!(private, public)).is_err()); + assert!(ProcedureArgs::parse(quote!(internal, internal)).is_err()); + let function: ItemFn = syn::parse_quote!( + fn example(ctx: &mut ProcedureContext) -> u64 { + 0 + } + ); + for (input, expected) in [ + (quote!(internal), "Internal"), + (quote!(private), "Private"), + (quote!(public), "ClientCallable"), + ] { + let tokens = procedure_impl(ProcedureArgs::parse(input).unwrap(), &function) + .unwrap() + .to_string(); + assert!(tokens.contains("DECLARED_VISIBILITY")); + assert!(tokens.contains(&format!("FunctionVisibility :: {expected}"))); + } + } +} diff --git a/crates/bindings-macro/src/reducer.rs b/crates/bindings-macro/src/reducer.rs index ac261ced35f..3093a51397a 100644 --- a/crates/bindings-macro/src/reducer.rs +++ b/crates/bindings-macro/src/reducer.rs @@ -10,6 +10,44 @@ use syn::{FnArg, Ident, ItemFn, LitStr, PatType}; pub(crate) struct ReducerArgs { name: Option, lifecycle: Option, + visibility: Option, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum DeclaredVisibility { + Internal, + Private, + Public, +} + +impl DeclaredVisibility { + pub(crate) fn tokens(self) -> TokenStream { + let variant = match self { + Self::Internal => "Internal", + Self::Private => "Private", + Self::Public => "ClientCallable", + }; + let variant = Ident::new(variant, Span::call_site()); + quote!(spacetimedb::rt::FunctionVisibility::#variant) + } +} + +pub(crate) fn parse_visibility( + meta: &syn::meta::ParseNestedMeta<'_>, + visibility: &mut Option, +) -> syn::Result { + let value = if meta.path.is_ident("internal") { + DeclaredVisibility::Internal + } else if meta.path.is_ident("private") { + DeclaredVisibility::Private + } else if meta.path.is_ident("public") { + DeclaredVisibility::Public + } else { + return Ok(false); + }; + check_duplicate_msg(visibility, meta, "already specified a function visibility")?; + *visibility = Some(value); + Ok(true) } enum LifecycleReducer { @@ -37,6 +75,9 @@ impl ReducerArgs { pub(crate) fn parse(input: TokenStream) -> syn::Result { let mut args = Self::default(); syn::meta::parser(|meta| { + if parse_visibility(&meta, &mut args.visibility)? { + return Ok(()); + } let mut set_lifecycle = |kind: fn(Span) -> _| -> syn::Result<()> { check_duplicate_msg(&args.lifecycle, &meta, "already specified a lifecycle reducer kind")?; args.lifecycle = Some(kind(meta.path.span())); @@ -55,6 +96,12 @@ impl ReducerArgs { Ok(()) }) .parse2(input)?; + if args.lifecycle.is_some() && args.visibility.is_some_and(|v| v != DeclaredVisibility::Internal) { + return Err(syn::Error::new( + Span::call_site(), + "lifecycle reducers must have internal visibility", + )); + } Ok(args) } } @@ -101,6 +148,7 @@ pub(crate) fn reducer_impl(args: ReducerArgs, original_function: &ItemFn) -> syn assert_only_lifetime_generics(original_function, "reducers")?; let lifecycle = args.lifecycle.iter().filter_map(|lc| lc.to_lifecycle_value()); + let visibility = args.visibility.map(DeclaredVisibility::tokens).into_iter(); let typed_args = extract_typed_args(original_function)?; @@ -165,6 +213,7 @@ pub(crate) fn reducer_impl(args: ReducerArgs, original_function: &ItemFn) -> syn /// The function kind, which will cause scheduled tables to accept reducers. type FnKind = spacetimedb::rt::FnKindReducer; const NAME: &'static str = #reducer_name; + #(const DECLARED_VISIBILITY: Option = Some(#visibility);)* #(const LIFECYCLE: Option = Some(#lifecycle);)* const ARG_NAMES: &'static [Option<&'static str>] = &[#(#opt_arg_names),*]; const INVOKE: Self::Invoke = #func_name::invoke; @@ -202,3 +251,43 @@ pub(crate) fn generate_explicit_names_impl( } } } + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn visibility_declarations_are_unambiguous() { + for input in [ + quote!(private, public), + quote!(internal, internal), + quote!(init, private), + quote!(public, client_connected), + ] { + assert!(ReducerArgs::parse(input).is_err()); + } + for input in [ + quote!(), + quote!(public), + quote!(private), + quote!(internal), + quote!(init, internal), + ] { + assert!(ReducerArgs::parse(input).is_ok()); + } + } + #[test] + fn rust_item_visibility_does_not_select_database_visibility() { + let function: ItemFn = syn::parse_quote!( + pub fn example(ctx: &ReducerContext) {} + ); + let implicit = reducer_impl(ReducerArgs::parse(quote!()).unwrap(), &function) + .unwrap() + .to_string(); + assert!(!implicit.contains("DECLARED_VISIBILITY")); + let explicit = reducer_impl(ReducerArgs::parse(quote!(internal)).unwrap(), &function) + .unwrap() + .to_string(); + assert!(explicit.contains("DECLARED_VISIBILITY")); + assert!(explicit.contains("FunctionVisibility :: Internal")); + } +} diff --git a/crates/bindings-sys/src/lib.rs b/crates/bindings-sys/src/lib.rs index 2a3bd77b454..235a25d545f 100644 --- a/crates/bindings-sys/src/lib.rs +++ b/crates/bindings-sys/src/lib.rs @@ -883,7 +883,14 @@ pub mod raw { pub fn datastore_clear(table_id: TableId, out: *mut u64) -> u16; } - // ABI10.6 is reserved for the separate invocation-authority extension. + #[link(wasm_import_module = "spacetime_10.6")] + unsafe extern "C" { + /// Authentication flags for the active invocation. Bit 0 is INTERNAL. + /// Read at context construction; neither a missing connection ID nor JWT + /// claims imply internal authority. Unknown bits must be ignored. + pub fn get_call_auth_flags() -> u32; + } + #[link(wasm_import_module = "spacetime_10.7")] unsafe extern "C" { /// Read a UTF-8 environment value. Writes INVALID for a missing key; @@ -1676,3 +1683,10 @@ pub mod procedure { } } } + +/// Read host-verified authentication flags for the active invocation. +/// Bit 0 is INTERNAL; all other bits are reserved. +pub fn get_call_auth_flags() -> u32 { + // SAFETY: no pointers or guest-provided values are passed to the host. + unsafe { raw::get_call_auth_flags() } +} diff --git a/crates/bindings-typescript/README.md b/crates/bindings-typescript/README.md index 48e7cfd1535..82c0b9ece87 100644 --- a/crates/bindings-typescript/README.md +++ b/crates/bindings-typescript/README.md @@ -18,6 +18,26 @@ You can use the package in the browser, using a bundler like vite/parcel/rsbuild ### Usage +#### Module function visibility and invocation authentication + +Reducer and procedure options accept `visibility: 'public'`, `'private'`, or +`'internal'`. For example, `spacetime.reducer({ visibility: 'internal' }, ctx => {})` +declares an internal reducer. Omission means public for ordinary functions and +private for scheduled functions. An explicit choice is preserved when the +function is scheduled. Lifecycle reducers permit only omission or `'internal'` +and can only run for their host lifecycle event. + +Internal functions require verified internal authority. Private functions also +admit the owner, and public functions admit any client. `ctx.senderAuth.isInternal` +captures the host's invocation authority independently of connection and JWT +presence, so an internal call can have a JWT. `ctx.senderAuth.jwt.identity` is the +verified sender supplied by the host. Procedure transactions preserve this +authentication. Newly compiled modules retain schema V10 and advertise +`hosted_auth_v1`. The extended visibility values and capability section require +a compatible host; older V10 definitions retain their existing defaults. + +#### Client SDK + In order to connect to a database you have to generate module bindings for your database. ```ts diff --git a/crates/bindings-typescript/src/lib/autogen/types.ts b/crates/bindings-typescript/src/lib/autogen/types.ts index 3cee51f03d5..8206f799e69 100644 --- a/crates/bindings-typescript/src/lib/autogen/types.ts +++ b/crates/bindings-typescript/src/lib/autogen/types.ts @@ -76,6 +76,8 @@ export type ExplicitNames = __Infer; export const FunctionVisibility = __t.enum('FunctionVisibility', { Private: __t.unit(), ClientCallable: __t.unit(), + Internal: __t.unit(), + ExplicitClientCallable: __t.unit(), }); export type FunctionVisibility = __Infer; @@ -393,6 +395,7 @@ export const RawModuleDefV10Section = __t.enum('RawModuleDefV10Section', { get Submodules() { return __t.array(RawSubmoduleV10); }, + Capabilities: __t.array(__t.string()), }); export type RawModuleDefV10Section = __Infer; diff --git a/crates/bindings-typescript/src/lib/reducers.ts b/crates/bindings-typescript/src/lib/reducers.ts index eaa8de94b55..04c141bc104 100644 --- a/crates/bindings-typescript/src/lib/reducers.ts +++ b/crates/bindings-typescript/src/lib/reducers.ts @@ -61,7 +61,7 @@ export type Reducer = ( * Authentication information for the caller of a reducer. */ export type AuthCtx = Readonly<{ - /** Whether the caller is an internal system process. */ + /** Whether the host verified internal invocation authority. Independent of JWT presence. */ isInternal: boolean; /** Whether the caller has authenticated with a JWT token. */ hasJWT: boolean; @@ -93,7 +93,7 @@ export interface JwtClaims { readonly issuer: string; /** The audience of the JWT token ('aud') */ readonly audience: readonly string[]; - /** The identity associated with the JWT token, which is based on the sub and iss */ + /** The verified sender Identity provided by the host, including hosted credentials. */ readonly identity: Identity; /** The full payload as a JsonObject */ readonly fullPayload: JsonObject; diff --git a/crates/bindings-typescript/src/lib/schema.ts b/crates/bindings-typescript/src/lib/schema.ts index eb3821f6606..762b4f9079a 100644 --- a/crates/bindings-typescript/src/lib/schema.ts +++ b/crates/bindings-typescript/src/lib/schema.ts @@ -200,6 +200,7 @@ export class ModuleContext { lifeCycleReducers: [], httpHandlers: [], httpRoutes: [], + capabilities: ['hosted_auth_v1'], caseConversionPolicy: { tag: 'SnakeCase' }, explicitNames: { entries: [], @@ -221,6 +222,7 @@ export class ModuleContext { const module = this.#moduleDef; push(module.typespace && { tag: 'Typespace', value: module.typespace }); + push({ tag: 'Capabilities', value: module.capabilities }); push(module.types && { tag: 'Types', value: module.types }); push(module.tables && { tag: 'Tables', value: module.tables }); push(module.reducers && { tag: 'Reducers', value: module.reducers }); diff --git a/crates/bindings-typescript/src/server/function_visibility.ts b/crates/bindings-typescript/src/server/function_visibility.ts new file mode 100644 index 00000000000..658fb1dad0d --- /dev/null +++ b/crates/bindings-typescript/src/server/function_visibility.ts @@ -0,0 +1,24 @@ +import { FunctionVisibility as RawFunctionVisibility } from '../lib/autogen/types'; + +/** Internal functions require verified internal authority. Private functions also + * admit the owner. Public functions admit any authenticated client. */ +export type FunctionVisibility = 'public' | 'private' | 'internal'; + +export function rawVisibility( + visibility: FunctionVisibility | undefined +): RawFunctionVisibility { + switch (visibility) { + case undefined: + // Preserve V10's existing context-dependent default, including scheduled + // private functions, without changing the raw definition's field layout. + return RawFunctionVisibility.ClientCallable; + case 'public': + return RawFunctionVisibility.ExplicitClientCallable; + case 'private': + return RawFunctionVisibility.Private; + case 'internal': + return RawFunctionVisibility.Internal; + default: + throw new TypeError('Invalid function visibility'); + } +} diff --git a/crates/bindings-typescript/src/server/index.ts b/crates/bindings-typescript/src/server/index.ts index 3ac3e8f0fbb..fdf3f45a224 100644 --- a/crates/bindings-typescript/src/server/index.ts +++ b/crates/bindings-typescript/src/server/index.ts @@ -10,6 +10,7 @@ export { table } from '../lib/table'; export { SenderError, SpacetimeHostError, errors } from './errors'; export type { Reducer, ReducerCtx, JwtClaims, AuthCtx } from '../lib/reducers'; export type { ReducerExport } from './reducers'; +export type { FunctionVisibility } from './function_visibility'; export { type DbView } from './db_view'; export * from './query'; export type { diff --git a/crates/bindings-typescript/src/server/procedures.ts b/crates/bindings-typescript/src/server/procedures.ts index f4d57416e69..076429dd80b 100644 --- a/crates/bindings-typescript/src/server/procedures.ts +++ b/crates/bindings-typescript/src/server/procedures.ts @@ -5,12 +5,12 @@ import { type Deserializer, type Serializer, } from '../lib/algebraic_type'; -import { FunctionVisibility } from '../lib/autogen/types'; +import { rawVisibility, type FunctionVisibility } from './function_visibility'; import BinaryReader from '../lib/binary_reader'; import BinaryWriter from '../lib/binary_writer'; import type { ConnectionId } from '../lib/connection_id'; import { Identity } from '../lib/identity'; -import type { ParamsObj, ReducerCtx } from '../lib/reducers'; +import type { AuthCtx, ParamsObj, ReducerCtx } from '../lib/reducers'; import { type UntypedSchemaDef } from '../lib/schema'; import type { ScheduleTableForParams } from '../lib/table_schema'; import { Timestamp } from '../lib/timestamp'; @@ -28,6 +28,7 @@ import { makeRandom, type Random } from './rng'; import { assignTxAliasViews, buildProcedureAliasCtxMap, + AuthCtxImpl, callUserFunction, ReducerCtxImpl, runWithTx, @@ -58,21 +59,19 @@ export function makeProcedureExport< ret: Ret, fn: ProcedureFn ): ProcedureExport { - const name = opts?.name; - const procedureExport: ProcedureExport = (...args) => fn(...args); procedureExport[exportContext] = ctx; procedureExport[registerExport] = (ctx, exportName) => { - registerProcedure(ctx, name ?? exportName, params, ret, fn); + registerProcedure(ctx, exportName, params, ret, fn, opts); ctx.functionExports.set( procedureExport as ProcedureExport, - name ?? exportName + exportName ); if (opts?.onSchedule !== undefined) { ctx.pendingSchedules.push({ table: opts.onSchedule, - functionName: name ?? exportName, + functionName: opts.name ?? exportName, }); } }; @@ -90,7 +89,9 @@ export interface ProcedureOpts< Params extends ParamsObj = ParamsObj, Ret extends TypeBuilder = TypeBuilder, > { - name: string; + name?: string; + /** Defaults to public, or private when scheduled. */ + visibility?: FunctionVisibility; onSchedule?: Ret extends ReturnType ? ScheduleTableForParams : never; @@ -116,6 +117,7 @@ export interface ProcedureCtx { readonly identity: Identity; readonly timestamp: Timestamp; readonly connectionId: ConnectionId | null; + readonly senderAuth: AuthCtx; readonly http: HttpClient; readonly random: Random; readonly as: ProcedureAliasViews; @@ -128,12 +130,6 @@ export interface ProcedureCtx { export interface TransactionCtx extends ReducerCtx {} -type ITransactionCtx = TransactionCtx; - -const TransactionCtxImpl = class TransactionCtx - extends ReducerCtxImpl - implements ITransactionCtx {}; - function registerProcedure< S extends UntypedSchemaDef, Params extends ParamsObj, @@ -161,7 +157,7 @@ function registerProcedure< sourceName: exportName, params: paramsType, returnType, - visibility: FunctionVisibility.ClientCallable, + visibility: rawVisibility(opts?.visibility), }); if (opts?.name != null) { @@ -232,6 +228,7 @@ const ProcedureCtxImpl = class ProcedureCtx #dispatches: SubmoduleDispatchInfo[]; #parentPrefix: string; #asViews: object | undefined; + readonly senderAuth: AuthCtx; constructor( readonly sender: Identity, @@ -244,6 +241,11 @@ const ProcedureCtxImpl = class ProcedureCtx this.#dbView = dbView; this.#dispatches = dispatches; this.#parentPrefix = parentPrefix; + this.senderAuth = AuthCtxImpl.fromSystemTables( + connectionId, + sender, + sys.get_call_auth_flags() + ); } get databaseIdentity() { @@ -274,11 +276,13 @@ const ProcedureCtxImpl = class ProcedureCtx const dispatches = this.#dispatches; const parentPrefix = this.#parentPrefix; return runWithTx(timestamp => { - const tx = new TransactionCtxImpl( + const tx = new ReducerCtxImpl( this.sender, timestamp, this.connectionId, - this.#dbView() + this.#dbView(), + {}, + this.senderAuth ); assignTxAliasViews(tx, dispatches, parentPrefix); return tx as unknown as TransactionCtx; diff --git a/crates/bindings-typescript/src/server/reducers.ts b/crates/bindings-typescript/src/server/reducers.ts index ea5f770faf8..25de0c98820 100644 --- a/crates/bindings-typescript/src/server/reducers.ts +++ b/crates/bindings-typescript/src/server/reducers.ts @@ -1,5 +1,6 @@ import { AlgebraicType } from '../lib/algebraic_type'; -import { FunctionVisibility, type Lifecycle } from '../lib/autogen/types'; +import { type Lifecycle } from '../lib/autogen/types'; +import { rawVisibility, type FunctionVisibility } from './function_visibility'; import type { ParamsObj, Reducer } from '../lib/reducers'; import { type UntypedSchemaDef } from '../lib/schema'; import type { ScheduleTableForParams } from '../lib/table_schema'; @@ -19,7 +20,9 @@ export interface ReducerExport< ModuleExport {} export interface ReducerOpts { - name: string; + name?: string; + /** Defaults to public, or private when scheduled. Lifecycle hooks are internal. */ + visibility?: FunctionVisibility; onSchedule?: ScheduleTableForParams; } @@ -84,12 +87,19 @@ export function registerReducer( const ref = ctx.registerTypesRecursively(params); const paramsType = ctx.resolveType(ref).value; const isLifecycle = lifecycle != null; + if ( + isLifecycle && + opts?.visibility != null && + opts.visibility !== 'internal' + ) { + throw new TypeError('Lifecycle reducers only support internal visibility'); + } ctx.moduleDef.reducers.push({ sourceName: exportName, params: paramsType, - //ModuleDef validation code is responsible to mark private reducers - visibility: FunctionVisibility.ClientCallable, + // Keep the legacy default distinct from an explicit public declaration. + visibility: rawVisibility(opts?.visibility), //Hardcoded for now - reducers do not return values yet okReturnType: AlgebraicType.Product({ elements: [] }), errReturnType: AlgebraicType.String, diff --git a/crates/bindings-typescript/src/server/runtime.ts b/crates/bindings-typescript/src/server/runtime.ts index 1fac18ac95b..aaa50ad186d 100644 --- a/crates/bindings-typescript/src/server/runtime.ts +++ b/crates/bindings-typescript/src/server/runtime.ts @@ -1,6 +1,7 @@ import { environment } from './environment'; import * as _syscalls2_0 from 'spacetime:sys@2.0'; import * as _syscalls2_1 from 'spacetime:sys@2.1'; +import * as _syscalls2_2 from 'spacetime:sys@2.2'; import type { ModuleHooks, u128, u16, u256, u32 } from 'spacetime:sys@2.0'; import { @@ -80,7 +81,7 @@ import { HttpRequest, HttpResponse } from '../lib/autogen/types'; const { freeze } = Object; -export const sys = { ..._syscalls2_0, ..._syscalls2_1 }; +export const sys = { ..._syscalls2_0, ..._syscalls2_1, ..._syscalls2_2 }; function requestFromWire(request: HttpRequest, body: Uint8Array): Request { return Request[makeRequest](body, { @@ -125,7 +126,8 @@ class JwtClaimsImpl implements JwtClaims { /** * Creates a new JwtClaims instance. * @param rawPayload The JWT payload as a raw JSON string. - * @param identity The identity for this JWT. We are only taking this because we don't have a blake3 implementation (which we need to compute it). + * @param identity The verified sender Identity supplied by the host. Claims + * cannot override it, including for hosted database credentials. */ constructor( public readonly rawPayload: string, @@ -153,7 +155,7 @@ class JwtClaimsImpl implements JwtClaims { } } -class AuthCtxImpl implements AuthCtx { +export class AuthCtxImpl implements AuthCtx { public readonly isInternal: boolean; // Source of the JWT payload string, if there is one. @@ -199,29 +201,21 @@ class AuthCtxImpl implements AuthCtx { return this._jwtClaims!; } - /** Create a context representing internal (non-user) requests. */ - static internal(): AuthCtx { - return new AuthCtxImpl({ - isInternal: true, - jwtSource: () => null, - senderIdentity: Identity.zero(), - }); - } - /** If there is a connection id, look up the JWT payload from the system tables. */ static fromSystemTables( connectionId: ConnectionId | null, - sender: Identity + sender: Identity, + callAuthFlags: number ): AuthCtx { if (connectionId === null) { return new AuthCtxImpl({ - isInternal: false, + isInternal: (callAuthFlags & 1) !== 0, jwtSource: () => null, senderIdentity: sender, }); } return new AuthCtxImpl({ - isInternal: false, + isInternal: (callAuthFlags & 1) !== 0, jwtSource: () => { const payloadBuf = sys.get_jwt_payload(connectionId.__connection_id__); if (payloadBuf.length === 0) return null; @@ -240,7 +234,7 @@ export const ReducerCtxImpl = class ReducerCtx< > implements IReducerCtx { #identity: Identity | undefined; - #senderAuth: AuthCtx | undefined; + #senderAuth: AuthCtx; #uuidCounter: { value: number } | undefined; #random: Random | undefined; sender: Identity; @@ -255,7 +249,8 @@ export const ReducerCtxImpl = class ReducerCtx< timestamp: Timestamp, connectionId: ConnectionId | null, dbView: DbView, - asViews: object = {} + asViews: object = {}, + senderAuth?: AuthCtx ) { Object.seal(this); this.sender = sender; @@ -263,6 +258,13 @@ export const ReducerCtxImpl = class ReducerCtx< this.connectionId = connectionId; this.db = dbView as unknown as DbView; this.as = asViews as AliasViews; + this.#senderAuth = + senderAuth ?? + AuthCtxImpl.fromSystemTables( + connectionId, + sender, + sys.get_call_auth_flags() + ); } /** Reset the `ReducerCtx` to be used for a new transaction */ @@ -278,7 +280,11 @@ export const ReducerCtxImpl = class ReducerCtx< me.timestamp = timestamp; me.connectionId = connectionId; me.#uuidCounter = undefined; - me.#senderAuth = undefined; + me.#senderAuth = AuthCtxImpl.fromSystemTables( + connectionId, + sender, + sys.get_call_auth_flags() + ); if (dbView !== undefined) { me.db = dbView; } @@ -296,10 +302,7 @@ export const ReducerCtxImpl = class ReducerCtx< } get senderAuth() { - return (this.#senderAuth ??= AuthCtxImpl.fromSystemTables( - this.connectionId, - this.sender - )); + return this.#senderAuth; } get random() { diff --git a/crates/bindings-typescript/src/server/schema.ts b/crates/bindings-typescript/src/server/schema.ts index c399a66d31e..648b5c42fc3 100644 --- a/crates/bindings-typescript/src/server/schema.ts +++ b/crates/bindings-typescript/src/server/schema.ts @@ -405,7 +405,10 @@ export class Schema implements ModuleDefaultExport { case 2: { let arg1; [arg1, fn] = args; - if (typeof arg1.name === 'string') + if ( + typeof arg1.name === 'string' || + typeof arg1.visibility === 'string' + ) opts = arg1 as ReducerOptsWithOptionalName; else params = arg1 as Params; break; @@ -644,7 +647,10 @@ export class Schema implements ModuleDefaultExport { case 3: { let arg1; [arg1, ret, fn] = args; - if (typeof arg1.name === 'string') + if ( + typeof arg1.name === 'string' || + typeof arg1.visibility === 'string' + ) opts = arg1 as ProcedureOptsWithOptionalName; else params = arg1 as Params; break; diff --git a/crates/bindings-typescript/src/server/sys.d.ts b/crates/bindings-typescript/src/server/sys.d.ts index 1f74debd2fc..32addabb9e7 100644 --- a/crates/bindings-typescript/src/server/sys.d.ts +++ b/crates/bindings-typescript/src/server/sys.d.ts @@ -124,7 +124,10 @@ declare module 'spacetime:sys@2.1' { export function datastore_clear(table_id: u32): u64; } -// sys2.2 is reserved for the separate invocation-authority extension. +declare module 'spacetime:sys@2.2' { + /** Verified invocation flags. Bit 0 is INTERNAL; JWT presence is independent. */ + export function get_call_auth_flags(): number; +} declare module 'spacetime:sys@2.3' { /** Null means missing; an empty string is a present value. */ diff --git a/crates/bindings-typescript/tests/__mocks__/spacetime-auth.ts b/crates/bindings-typescript/tests/__mocks__/spacetime-auth.ts new file mode 100644 index 00000000000..940221f4710 --- /dev/null +++ b/crates/bindings-typescript/tests/__mocks__/spacetime-auth.ts @@ -0,0 +1,2 @@ +// Ordinary host calls are external unless a test supplies trusted flags. +export const get_call_auth_flags = (): number => 0; diff --git a/crates/bindings-typescript/tests/hosted_auth.test.ts b/crates/bindings-typescript/tests/hosted_auth.test.ts new file mode 100644 index 00000000000..047aaf2d196 --- /dev/null +++ b/crates/bindings-typescript/tests/hosted_auth.test.ts @@ -0,0 +1,303 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const host = vi.hoisted(() => ({ + flags: 0, + payload: '', + jwtReads: 0, + flagReads: 0, +})); +vi.mock('spacetime:sys@2.0', () => ({ + moduleHooks: Symbol('moduleHooks'), + identity: () => 1n, + row_iter_bsatn_close: () => {}, + procedure_start_mut_tx: () => 0n, + procedure_commit_mut_tx: () => {}, + procedure_abort_mut_tx: () => {}, + get_jwt_payload: () => { + host.jwtReads++; + return new TextEncoder().encode(host.payload); + }, +})); +vi.mock('spacetime:sys@2.2', () => ({ + get_call_auth_flags: () => { + host.flagReads++; + return host.flags; + }, +})); + +import { ReducerCtxImpl } from '../src/server/runtime'; +import { ConnectionId } from '../src/lib/connection_id'; +import { Identity } from '../src/lib/identity'; +import { Timestamp } from '../src/lib/timestamp'; +import { schema, exportContext, registerExport } from '../src/server/schema'; +import { callProcedure } from '../src/server/procedures'; +import { t } from '../src/lib/type_builders'; +import { + AlgebraicType, + FunctionVisibility, + ProductType, + RawModuleDef, + RawModuleDefV10Section, + RawReducerDefV10, +} from '../src/lib/autogen/types'; +import BinaryReader from '../src/lib/binary_reader'; +import BinaryWriter from '../src/lib/binary_writer'; + +beforeEach(() => { + Object.assign(host, { flags: 0, payload: '', jwtReads: 0, flagReads: 0 }); +}); + +describe('verified invocation authentication', () => { + it.each([0, 1])( + 'preserves flag %s for calls without a connection or JWT', + flags => { + host.flags = flags; + const ctx = new ReducerCtxImpl( + new Identity(1n), + Timestamp.UNIX_EPOCH, + null, + {} + ); + host.flags = flags ^ 1; + expect(host.flagReads).toBe(1); + expect(ctx.senderAuth.isInternal).toBe(Boolean(flags)); + expect(ctx.senderAuth.hasJWT).toBe(false); + expect(ctx.senderAuth.jwt).toBeNull(); + expect(host.jwtReads).toBe(0); + } + ); + + it('retains an internal connection and JWT independently, using the verified sender Identity', () => { + host.flags = 1; + host.payload = JSON.stringify({ + iss: 'unrelated-issuer', + sub: 'unrelated-subject', + identity: 'untrusted-claim', + }); + const sender = new Identity(123n); + const connection = new ConnectionId(7n); + const ctx = new ReducerCtxImpl( + sender, + Timestamp.UNIX_EPOCH, + connection, + {} + ); + host.flags = 0; + expect(ctx.connectionId).toBe(connection); + expect(ctx.senderAuth.isInternal).toBe(true); + expect(host.jwtReads).toBe(0); + expect(ctx.senderAuth.hasJWT).toBe(true); + expect(ctx.senderAuth.jwt?.identity).toBe(sender); + expect(ctx.senderAuth.jwt?.subject).toBe('unrelated-subject'); + expect(host.jwtReads).toBe(1); + }); + + it('refreshes captured flags and sender when a cached reducer context is reused', () => { + host.flags = 1; + const ctx = new ReducerCtxImpl( + new Identity(1n), + Timestamp.UNIX_EPOCH, + null, + {} + ); + const firstAuth = ctx.senderAuth; + host.flags = 0; + ReducerCtxImpl.reset( + ctx, + new Identity(2n), + Timestamp.UNIX_EPOCH, + new ConnectionId(8n) + ); + host.flags = 1; + expect(firstAuth.isInternal).toBe(true); + expect(ctx.senderAuth.isInternal).toBe(false); + expect(ctx.senderAuth.hasJWT).toBe(false); + }); + + it('preserves procedure auth inside a transaction after the host flags change', () => { + host.flags = 1; + const module = schema({}); + const proc = module.procedure(t.unit(), ctx => { + host.flags = 0; + ctx.withTx(tx => { + expect(tx.senderAuth).toBe(ctx.senderAuth); + expect(tx.senderAuth.isInternal).toBe(true); + expect(tx.connectionId).toBe(ctx.connectionId); + }); + return {}; + }); + const inner = proc[exportContext]!; + proc[registerExport](inner, 'procedure_auth'); + callProcedure( + inner.procedures, + 0, + new Identity(9n), + new ConnectionId(8n), + Timestamp.UNIX_EPOCH, + new Uint8Array(), + () => ({}) + ); + expect(host.flagReads).toBe(1); + }); +}); + +describe('V10 explicit function visibility', () => { + it('preserves existing visibility tags and appends the new variants and capability section', () => { + const legacyVisibility = t.enum('LegacyFunctionVisibility', { + Private: t.unit(), + ClientCallable: t.unit(), + }); + const variants = [ + FunctionVisibility.Private, + FunctionVisibility.ClientCallable, + FunctionVisibility.Internal, + FunctionVisibility.ExplicitClientCallable, + ]; + for (const [tag, visibility] of variants.entries()) { + const writer = new BinaryWriter(8); + FunctionVisibility.serialize(writer, visibility); + expect([...writer.getBuffer()]).toEqual([tag]); + const reader = new BinaryReader(writer.getBuffer()); + if (tag < 2) { + expect(legacyVisibility.deserialize(reader).tag).toBe(visibility.tag); + } + } + const writer = new BinaryWriter(8); + RawModuleDefV10Section.serialize(writer, { + tag: 'Capabilities', + value: [], + }); + expect([...writer.getBuffer()]).toEqual([15, 0, 0, 0, 0]); + }); + + it('retains the V10 reducer field layout without an optional visibility wrapper', () => { + const module = schema({}); + const reducer = module.reducer({ visibility: 'public' }, () => {}); + const inner = reducer[exportContext]!; + reducer[registerExport](inner, 'public_reducer'); + const definition = inner.moduleDef.reducers[0]; + const writer = new BinaryWriter(128); + RawReducerDefV10.serialize(writer, definition); + const expected = new BinaryWriter(128); + expected.writeString(definition.sourceName); + ProductType.serialize(expected, definition.params); + expected.writeByte(3); + AlgebraicType.serialize(expected, definition.okReturnType); + AlgebraicType.serialize(expected, definition.errReturnType); + expect(writer.getBuffer()).toEqual(expected.getBuffer()); + }); + + it('serializes omission separately from explicit visibility and advertises hosted auth', () => { + const module = schema({}); + const omitted = module.reducer(() => {}); + const explicitlyPublic = module.reducer({ visibility: 'public' }, () => {}); + const privateReducer = module.reducer({ visibility: 'private' }, () => {}); + const internalReducer = module.reducer( + { visibility: 'internal' }, + () => {} + ); + const inner = omitted[exportContext]!; + for (const [name, reducer] of Object.entries({ + omitted, + explicitlyPublic, + privateReducer, + internalReducer, + })) { + reducer[registerExport](inner, name); + } + // Being scheduled must not erase a public choice or manufacture an explicit + // choice for the default. The host resolves the latter to Private. + for (const name of [ + 'omitted', + 'explicitlyPublic', + 'privateReducer', + 'internalReducer', + ]) { + inner.moduleDef.schedules.push({ + sourceName: undefined, + tableName: `jobs_${name}`, + scheduleAtCol: 0, + functionName: name, + }); + } + const raw = RawModuleDef.V10(inner.rawModuleDefV10()); + const writer = new BinaryWriter(128); + RawModuleDef.serialize(writer, raw); + expect(writer.getBuffer()[0]).toBe(2); + const decoded = RawModuleDef.deserialize( + new BinaryReader(writer.getBuffer()) + ); + const roundTrip = new BinaryWriter(128); + RawModuleDef.serialize(roundTrip, decoded); + expect(roundTrip.getBuffer()).toEqual(writer.getBuffer()); + expect(decoded.tag).toBe('V10'); + if (decoded.tag !== 'V10') throw new Error('Expected V10'); + const reducers = decoded.value.sections.find( + section => section.tag === 'Reducers' + ); + expect(reducers?.value.map(reducer => reducer.visibility.tag)).toEqual([ + 'ClientCallable', + 'ExplicitClientCallable', + 'Private', + 'Internal', + ]); + expect( + inner.moduleDef.reducers.map(reducer => reducer.visibility.tag) + ).toEqual([ + 'ClientCallable', + 'ExplicitClientCallable', + 'Private', + 'Internal', + ]); + expect(inner.moduleDef.capabilities).toEqual(['hosted_auth_v1']); + }); + + it('retains procedure names and explicit visibility, including a visibility parameter', () => { + const module = schema({}); + const proc = module.procedure( + { name: 'public_name', visibility: 'internal' }, + t.unit(), + () => ({}) + ); + const reducer = module.reducer({ visibility: t.string() }, () => {}); + const inner = proc[exportContext]!; + proc[registerExport](inner, 'source_name'); + reducer[registerExport](inner, 'accept_visibility'); + expect(inner.moduleDef.procedures[0].sourceName).toBe('source_name'); + expect(inner.moduleDef.procedures[0].visibility.tag).toBe('Internal'); + expect(inner.moduleDef.explicitNames.entries).toContainEqual({ + tag: 'Function', + value: { sourceName: 'source_name', canonicalName: 'public_name' }, + }); + expect(inner.moduleDef.reducers[0].params.elements[0].name).toBe( + 'visibility' + ); + }); + + it.each(['private', 'public'] as const)( + 'rejects explicit %s lifecycle declarations', + visibility => { + const module = schema({}); + const invalid = module.init({ visibility }, () => {}); + expect(() => + invalid[registerExport](invalid[exportContext]!, 'invalid_init') + ).toThrow('Lifecycle reducers only support internal visibility'); + } + ); + + it.each([undefined, 'internal'] as const)( + 'preserves permitted lifecycle declaration %s for host event dispatch', + visibility => { + const module = schema({}); + const valid = module.init({ visibility }, () => {}); + valid[registerExport](valid[exportContext]!, 'valid_init'); + const inner = valid[exportContext]!; + expect(inner.moduleDef.reducers[0].visibility.tag).toBe( + visibility === undefined ? 'ClientCallable' : 'Internal' + ); + expect(inner.moduleDef.lifeCycleReducers).toEqual([ + { lifecycleSpec: { tag: 'Init' }, functionName: 'valid_init' }, + ]); + } + ); +}); diff --git a/crates/bindings-typescript/vitest.config.ts b/crates/bindings-typescript/vitest.config.ts index 7cfc858d47c..9f80a60db65 100644 --- a/crates/bindings-typescript/vitest.config.ts +++ b/crates/bindings-typescript/vitest.config.ts @@ -14,6 +14,10 @@ export default defineConfig({ alias: [ { find: 'spacetime:sys@2.0', replacement: sysMock }, { find: 'spacetime:sys@2.1', replacement: sysMock }, + { + find: 'spacetime:sys@2.2', + replacement: fileURLToPath(new URL('./tests/__mocks__/spacetime-auth.ts', import.meta.url)), + }, ], }, test: { diff --git a/crates/bindings/src/http.rs b/crates/bindings/src/http.rs index 3638d35f9e2..1ad7a0edef5 100644 --- a/crates/bindings/src/http.rs +++ b/crates/bindings/src/http.rs @@ -11,7 +11,7 @@ use crate::IterBuf; #[cfg(all(feature = "unstable", feature = "rand08"))] use crate::StdbRng; #[cfg(feature = "unstable")] -use crate::{try_with_tx, with_tx, Timestamp, TxContext}; +use crate::{try_with_tx, with_tx, AuthCtx, Timestamp, TxContext}; use bytes::Bytes; #[cfg(all(feature = "rand08", feature = "unstable"))] use rand08::RngCore; @@ -106,6 +106,7 @@ pub struct HandlerContext { /// Methods for performing HTTP requests. pub http: HttpClient, + sender_auth: AuthCtx, #[cfg(feature = "rand08")] pub(crate) rng: OnceCell, @@ -123,6 +124,7 @@ impl HandlerContext { env: crate::Environment::default(), timestamp, http: HttpClient {}, + sender_auth: AuthCtx::from_invocation(Identity::ZERO, None), #[cfg(feature = "rand08")] rng: OnceCell::new(), #[cfg(feature = "rand08")] @@ -143,12 +145,12 @@ impl HandlerContext { /// Acquire a mutable transaction and execute `body` with read-write access. pub fn with_tx(&mut self, body: impl Fn(&TxContext) -> T) -> T { - with_tx(body, Identity::ZERO, None) + with_tx(Identity::ZERO, None, &self.sender_auth, body) } /// Acquire a mutable transaction and execute `body` with read-write access. pub fn try_with_tx(&mut self, body: impl Fn(&TxContext) -> Result) -> Result { - try_with_tx(body, Identity::ZERO, None) + try_with_tx(Identity::ZERO, None, &self.sender_auth, body) } /// Create a new random [`Uuid`] `v4` using the built-in RNG. diff --git a/crates/bindings/src/lib.rs b/crates/bindings/src/lib.rs index d1548bca052..b234c6c5c27 100644 --- a/crates/bindings/src/lib.rs +++ b/crates/bindings/src/lib.rs @@ -1081,13 +1081,24 @@ impl ReducerContext { #[doc(hidden)] fn new(db: Local, sender: Identity, connection_id: Option, timestamp: Timestamp) -> Self { + let sender_auth = AuthCtx::from_invocation(sender, connection_id); + Self::new_with_auth(db, sender, connection_id, timestamp, sender_auth) + } + + fn new_with_auth( + db: Local, + sender: Identity, + connection_id: Option, + timestamp: Timestamp, + sender_auth: AuthCtx, + ) -> Self { Self { env: Environment::default(), db, sender, timestamp, connection_id, - sender_auth: AuthCtx::from_connection_id_opt(connection_id), + sender_auth, #[cfg(feature = "rand08")] rng: std::cell::OnceCell::new(), #[cfg(feature = "rand08")] @@ -1213,13 +1224,11 @@ impl Deref for TxContext { } } -/// We need to passthrough identity and connection_id because procedures can be invoked by users. -/// For [HttpContext] this is always anonymous ([Identity::ZERO]). -/// Construct the inner [ReducerContext] with the appropriate caller information. fn try_with_tx( - body: impl Fn(&TxContext) -> Result, - identity: Identity, + sender: Identity, connection_id: Option, + sender_auth: &AuthCtx, + body: impl Fn(&TxContext) -> Result, ) -> Result { let abort = || { crate::sys::procedure::procedure_abort_mut_tx() @@ -1231,7 +1240,8 @@ fn try_with_tx( .expect("holding `&mut HandlerContext`, so should not be in a tx already; called manually elsewhere?"); let timestamp = Timestamp::from_micros_since_unix_epoch(timestamp); - let tx = ReducerContext::new(crate::Local {}, identity, connection_id, timestamp); + // Every retry retains the original invocation's identity and authority. + let tx = ReducerContext::new_with_auth(crate::Local {}, sender, connection_id, timestamp, sender_auth.clone()); let tx = TxContext(tx); struct DoOnDrop(F); @@ -1264,9 +1274,14 @@ fn try_with_tx( res } -fn with_tx(body: impl Fn(&TxContext) -> T, identity: Identity, connection_id: Option) -> T { +fn with_tx( + sender: Identity, + connection_id: Option, + sender_auth: &AuthCtx, + body: impl Fn(&TxContext) -> T, +) -> T { use core::convert::Infallible; - match try_with_tx::(|tx| Ok(body(tx)), identity, connection_id) { + match try_with_tx::(sender, connection_id, sender_auth, |tx| Ok(body(tx))) { Ok(v) => v, Err(e) => match e {}, } @@ -1292,6 +1307,7 @@ pub struct ProcedureContext { /// /// Will be `None` for certain scheduled procedures. connection_id: Option, + sender_auth: AuthCtx, /// Methods for performing HTTP requests. pub http: crate::http::HttpClient, @@ -1314,6 +1330,7 @@ impl ProcedureContext { timestamp, connection_id, env: Environment::default(), + sender_auth: AuthCtx::from_invocation(sender, connection_id), http: http::HttpClient {}, #[cfg(feature = "rand08")] rng: std::cell::OnceCell::new(), @@ -1322,6 +1339,11 @@ impl ProcedureContext { } } + /// Host-verified authentication for this invocation, retained in transactions. + pub fn sender_auth(&self) -> &AuthCtx { + &self.sender_auth + } + /// The `Identity` of the client that invoked the procedure. pub fn sender(&self) -> Identity { self.sender @@ -1407,7 +1429,7 @@ impl ProcedureContext { /// callers should avoid writing to any captured mutable state within `body`, /// This includes interior mutability through types like [`std::cell::Cell`]. pub fn with_tx(&mut self, body: impl Fn(&TxContext) -> T) -> T { - with_tx(body, self.sender(), self.connection_id()) + with_tx(self.sender, self.connection_id, &self.sender_auth, body) } /// Acquire a mutable transaction @@ -1440,7 +1462,7 @@ impl ProcedureContext { /// callers should avoid writing to any captured mutable state within `body`, /// This includes interior mutability through types like [`std::cell::Cell`]. pub fn try_with_tx(&mut self, body: impl Fn(&TxContext) -> Result) -> Result { - try_with_tx(body, self.sender(), self.connection_id()) + try_with_tx(self.sender, self.connection_id, &self.sender_auth, body) } /// Create a new random [`Uuid`] `v4` using the built-in RNG. @@ -1873,6 +1895,7 @@ impl CtxWithHttp for ProcedureContext { /// [JWT]: https://en.wikipedia.org/wiki/JSON_Web_Token #[non_exhaustive] pub struct JwtClaims { + identity: Identity, payload: String, parsed: OnceCell, audience: OnceCell>, @@ -1888,10 +1911,16 @@ pub struct AuthCtx { } impl AuthCtx { - /// Creates an [`AuthCtx`] both for cases where there's a [`ConnectionId`] - /// and for when there isn't. - fn from_connection_id_opt(conn_id: Option) -> Self { - conn_id.map(Self::from_connection_id).unwrap_or_else(Self::internal) + /// Capture host authority immediately. JWT loading remains independent and lazy. + fn from_invocation(sender: Identity, connection_id: Option) -> Self { + let flags = spacetimedb_bindings_sys::get_call_auth_flags(); + Self::from_host_auth(sender, flags, move || connection_id.and_then(rt::get_jwt)) + } + + fn from_host_auth(sender: Identity, flags: u32, jwt_fn: impl FnOnce() -> Option + 'static) -> Self { + Self::new(flags & 1 != 0, move || { + jwt_fn().map(|payload| JwtClaims::new(payload, sender)) + }) } fn new(is_internal: bool, jwt_fn: impl FnOnce() -> Option + 'static) -> Self { @@ -1914,14 +1943,18 @@ impl AuthCtx { /// /// [JWT]: https://en.wikipedia.org/wiki/JSON_Web_Token pub fn from_jwt_payload(jwt_payload: String) -> AuthCtx { - Self::new(false, move || Some(JwtClaims::new(jwt_payload))) + let parsed: serde_json::Value = serde_json::from_str(&jwt_payload).expect("invalid test JWT payload"); + let sender = Identity::from_claims( + parsed["iss"].as_str().expect("missing test issuer"), + parsed["sub"].as_str().expect("missing test subject"), + ); + Self::from_jwt_payload_for_sender(jwt_payload, sender, false) } - /// Creates an [`AuthCtx`] that reads the [JWT] for the given connection id. - /// - /// [JWT]: https://en.wikipedia.org/wiki/JSON_Web_Token - fn from_connection_id(connection_id: ConnectionId) -> AuthCtx { - Self::new(false, move || rt::get_jwt(connection_id).map(JwtClaims::new)) + /// Create test authentication with an explicit effective sender and authority. + /// Production contexts receive these values from the host, not JWT claims. + pub fn from_jwt_payload_for_sender(jwt_payload: String, sender: Identity, is_internal: bool) -> AuthCtx { + Self::new(is_internal, move || Some(JwtClaims::new(jwt_payload, sender))) } /// Returns whether this reducer was spawned from inside the database. @@ -1929,8 +1962,8 @@ impl AuthCtx { self.is_internal } - /// Checks if there is a [JWT] without loading it. - /// If [`AuthCtx::is_internal`] returns true, this will return false. + /// Returns whether this invocation has a [JWT]. Internal invocations may + /// also carry a JWT; internal authority and credential presence are independent. /// /// [JWT]: https://en.wikipedia.org/wiki/JSON_Web_Token pub fn has_jwt(&self) -> bool { @@ -1946,8 +1979,9 @@ impl AuthCtx { } impl JwtClaims { - fn new(jwt: String) -> Self { + fn new(jwt: String, identity: Identity) -> Self { Self { + identity, payload: jwt, parsed: OnceCell::new(), audience: OnceCell::new(), @@ -1989,10 +2023,10 @@ impl JwtClaims { self.audience.get_or_init(|| self.extract_audience()) } - /// Returns the identity for these credentials, which is - /// based on the iss and sub claims. + /// The effective sender verified by the host for this invocation. + /// Hosted database credentials need not derive this Identity from iss/sub. pub fn identity(&self) -> Identity { - Identity::from_claims(self.issuer(), self.subject()) + self.identity } /// Get the whole JWT payload as a json string. @@ -2145,3 +2179,54 @@ mod tests { assert_eq!(audience, &["my-project-id".to_string()]); } } + +#[cfg(test)] +mod hosted_auth_tests { + use super::*; + + #[test] + fn host_authority_is_independent_of_jwt_presence() { + for internal in [false, true] { + for has_jwt in [false, true] { + let flags = u32::from(internal) | (1 << 31); + let auth = AuthCtx::from_host_auth(Identity::ONE, flags, move || { + has_jwt.then(|| r#"{"iss":"hosted","sub":"generation","hex_identity":"untrusted"}"#.to_string()) + }); + assert_eq!(auth.is_internal(), internal); + assert_eq!(auth.has_jwt(), has_jwt); + if let Some(jwt) = auth.jwt() { + assert_eq!(jwt.identity(), Identity::ONE); + assert_ne!(jwt.identity(), Identity::from_claims(jwt.issuer(), jwt.subject())); + } + } + } + } + + #[test] + fn verified_sender_wins_over_signed_payload_identity_claims() { + let payload = format!( + r#"{{"iss":"hosted","sub":"generation","hex_identity":"{}"}}"#, + Identity::ZERO.to_hex() + ); + let auth = AuthCtx::from_host_auth(Identity::ONE, 1, move || Some(payload)); + assert_eq!(auth.jwt().unwrap().identity(), Identity::ONE); + assert!(auth.is_internal()); + assert!(auth.has_jwt()); + } + + #[test] + fn transaction_context_preserves_identity_connection_and_authentication() { + let sender = Identity::ONE; + let connection = Some(ConnectionId::from_u128(123)); + let auth = AuthCtx::from_host_auth(sender, 1, || Some(r#"{"iss":"hosted","sub":"generation"}"#.into())); + // Procedure transactions/retries call this same constructor with the + // original captured AuthCtx rather than manufacturing internal authority. + for timestamp in [Timestamp::UNIX_EPOCH, Timestamp::from_micros_since_unix_epoch(1)] { + let context = ReducerContext::new_with_auth(Local {}, sender, connection, timestamp, auth.clone()); + assert_eq!(context.sender(), sender); + assert_eq!(context.connection_id(), connection); + assert!(context.sender_auth().is_internal()); + assert_eq!(context.sender_auth().jwt().unwrap().identity(), sender); + } + } +} diff --git a/crates/bindings/src/rt.rs b/crates/bindings/src/rt.rs index b09c998f5c3..3ee8731225e 100644 --- a/crates/bindings/src/rt.rs +++ b/crates/bindings/src/rt.rs @@ -1,5 +1,7 @@ #![deny(unsafe_op_in_unsafe_fn)] +pub use spacetimedb_lib::db::raw_def::v10::FunctionVisibility; + use crate::query_builder::{FromWhere, HasCols, LeftSemiJoin, RawQuery, RightSemiJoin, Table as QbTable}; use crate::table::IndexAlgo; use crate::{sys, AnonymousViewContext, IterBuf, ReducerContext, ReducerResult, SpacetimeType, Table, ViewContext}; @@ -159,6 +161,9 @@ pub trait FnInfo: ExplicitNames { /// The lifecycle of the function, if there is one. const LIFECYCLE: Option = None; + /// Explicit SpacetimeDB visibility; Rust item visibility is independent. + const DECLARED_VISIBILITY: Option = None; + /// A description of the parameter names of the function. const ARG_NAMES: &'static [Option<&'static str>]; @@ -800,9 +805,13 @@ pub fn register_reducer<'a, A: Args<'a>, I: FnInfo>(_: impl register_describer(|module| { let params = A::schema::(&mut module.inner); if let Some(lifecycle) = I::LIFECYCLE { - module.inner.add_lifecycle_reducer(lifecycle, I::NAME, params); + module + .inner + .add_lifecycle_reducer_with_visibility(lifecycle, I::NAME, params, I::DECLARED_VISIBILITY); } else { - module.inner.add_reducer(I::NAME, params); + module + .inner + .add_reducer_with_visibility(I::NAME, params, I::DECLARED_VISIBILITY); } module.reducers.push(I::INVOKE); @@ -819,7 +828,9 @@ where register_describer(|module| { let params = A::schema::(&mut module.inner); let ret_ty = ::make_type(&mut module.inner); - module.inner.add_procedure(I::NAME, params, ret_ty); + module + .inner + .add_procedure_with_visibility(I::NAME, params, ret_ty, I::DECLARED_VISIBILITY); module.procedures.push(I::INVOKE); module.inner.add_explicit_names(I::explicit_names()); @@ -982,6 +993,9 @@ extern "C" fn __describe_module__(description: BytesSink) { describer(&mut module) } + // These bindings capture host flags and preserve the verified sender in JWT claims. + module.inner.add_capability("hosted_auth_v1"); + // Serialize the module to bsatn. let module_def = module.inner.finish(); let module_def = RawModuleDef::V10(module_def); diff --git a/crates/bindings/tests/pass/function_visibility.rs b/crates/bindings/tests/pass/function_visibility.rs new file mode 100644 index 00000000000..7f53af5717f --- /dev/null +++ b/crates/bindings/tests/pass/function_visibility.rs @@ -0,0 +1,62 @@ +#![deny(warnings)] + +use spacetimedb::rt::{FnInfo, FunctionVisibility}; +use spacetimedb::{ProcedureContext, ReducerContext}; + +#[spacetimedb::reducer(internal)] +pub fn internal_reducer(_ctx: &ReducerContext) {} + +#[spacetimedb::reducer(private)] +fn private_reducer(_ctx: &ReducerContext) {} + +#[spacetimedb::reducer(public)] +fn public_reducer(_ctx: &ReducerContext) {} + +#[spacetimedb::reducer(init, internal)] +fn initialize(_ctx: &ReducerContext) {} + +#[spacetimedb::procedure(internal)] +fn internal_procedure(_ctx: &mut ProcedureContext) -> u64 { + 0 +} + +#[spacetimedb::procedure(private)] +fn private_procedure(_ctx: &mut ProcedureContext) -> u64 { + 0 +} + +#[spacetimedb::procedure(public)] +fn public_procedure(_ctx: &mut ProcedureContext) -> u64 { + 0 +} + +fn main() { + assert!(matches!( + internal_reducer::DECLARED_VISIBILITY, + Some(FunctionVisibility::Internal) + )); + assert!(matches!( + private_reducer::DECLARED_VISIBILITY, + Some(FunctionVisibility::Private) + )); + assert!(matches!( + public_reducer::DECLARED_VISIBILITY, + Some(FunctionVisibility::ClientCallable) + )); + assert!(matches!( + initialize::DECLARED_VISIBILITY, + Some(FunctionVisibility::Internal) + )); + assert!(matches!( + internal_procedure::DECLARED_VISIBILITY, + Some(FunctionVisibility::Internal) + )); + assert!(matches!( + private_procedure::DECLARED_VISIBILITY, + Some(FunctionVisibility::Private) + )); + assert!(matches!( + public_procedure::DECLARED_VISIBILITY, + Some(FunctionVisibility::ClientCallable) + )); +} diff --git a/crates/bindings/tests/ui/tables.stderr b/crates/bindings/tests/ui/tables.stderr index 7609d9ba378..18b61f49224 100644 --- a/crates/bindings/tests/ui/tables.stderr +++ b/crates/bindings/tests/ui/tables.stderr @@ -209,13 +209,13 @@ error[E0277]: `&'a Alpha` cannot appear as an argument to an index filtering ope = note: The allowed set of types are limited to integers, bool, strings, `Identity`, `Uuid`, `Timestamp`, `ConnectionId`, `Hash` and no-payload enums which derive `SpacetimeType`, = help: the following other types implement trait `FilterableValue`: &ConnectionId + &ContainerMode &FunctionVisibility &Identity &Lifecycle - &TableAccess - &TableType - &bool - ðnum::int::I256 + &PortExposure + &PortProtocol + &RestartPolicy and $N others note: required by a bound in `UniqueColumn::::ColType, Col>::find` --> src/table.rs @@ -241,13 +241,13 @@ help: the trait `FilterableValue` is not implemented for `Alpha` | ^^^^^^^^^^^^ = help: the following other types implement trait `FilterableValue`: &ConnectionId + &ContainerMode &FunctionVisibility &Identity &Lifecycle - &TableAccess - &TableType - &bool - ðnum::int::I256 + &PortExposure + &PortProtocol + &RestartPolicy and $N others = note: required for `Alpha` to implement `IndexScanRangeBounds<(Alpha,), SingleBound>` note: required by a bound in `RangedIndex::::filter` diff --git a/crates/cli/src/subcommands/generate.rs b/crates/cli/src/subcommands/generate.rs index e312bf65ea9..56016694d32 100644 --- a/crates/cli/src/subcommands/generate.rs +++ b/crates/cli/src/subcommands/generate.rs @@ -267,7 +267,7 @@ pub fn cli() -> clap::Command { .long("include-private") .action(SetTrue) .default_value("false") - .help("Include private tables and functions in generated code (types are always included)."), + .help("Include private tables and private/internal non-lifecycle functions (types are always included)."), ) .arg(common_args::yes()) .arg( diff --git a/crates/client-api/src/routes/database.rs b/crates/client-api/src/routes/database.rs index f170f9b290e..9d3561b4542 100644 --- a/crates/client-api/src/routes/database.rs +++ b/crates/client-api/src/routes/database.rs @@ -533,7 +533,8 @@ where let module_def = &module.info.module_def; let response_json = match version { SchemaVersion::V9 => { - let raw = RawModuleDefV9::from(module_def.as_ref().clone()); + let raw = RawModuleDefV9::try_from(module_def.as_ref().clone()) + .map_err(|err| bad_request(err.to_string().into()))?; axum::Json(sats::serde::SerdeWrapper(raw)).into_response() } SchemaVersion::V10 => { diff --git a/crates/client-api/src/routes/mcp.rs b/crates/client-api/src/routes/mcp.rs index 2a5c2c156ec..b94f6739db3 100644 --- a/crates/client-api/src/routes/mcp.rs +++ b/crates/client-api/src/routes/mcp.rs @@ -9,7 +9,7 @@ use spacetimedb::auth::identity::ConnectionAuthCtx; use spacetimedb::host::{FunctionArgs, ReducerOutcome}; use spacetimedb::identity::Identity; use spacetimedb::messages::control_db::Database; -use spacetimedb_lib::db::raw_def::v9::RawModuleDefV9; +use spacetimedb_lib::db::raw_def::v10::RawModuleDefV10; use spacetimedb_lib::sats; use super::database::{ @@ -350,7 +350,7 @@ where let database = target.resolve(ctx, addressed).await?; let leader = find_database_leader(ctx, &database).await?; let module = leader.wait_for_module(MODULE_WAIT_TIMEOUT).await.map_err(log_and_500)?; - let raw = RawModuleDefV9::from(module.info.module_def.as_ref().clone()); + let raw = RawModuleDefV10::from(module.info.module_def.as_ref().clone()); let json = serde_json::to_string(&sats::serde::SerdeWrapper(raw)).map_err(log_and_500)?; Ok(json) } diff --git a/crates/codegen/src/util.rs b/crates/codegen/src/util.rs index 5a62afdd06f..fdb0e7fcce9 100644 --- a/crates/codegen/src/util.rs +++ b/crates/codegen/src/util.rs @@ -10,8 +10,8 @@ use convert_case::{Case, Casing}; use itertools::Itertools; use spacetimedb_lib::db::raw_def::v9::TableAccess; use spacetimedb_lib::sats::layout::PrimitiveType; +use spacetimedb_lib::sats::AlgebraicTypeRef; use spacetimedb_lib::version; -use spacetimedb_lib::{db::raw_def::v9::Lifecycle, sats::AlgebraicTypeRef}; use spacetimedb_primitives::ColList; use spacetimedb_schema::{def::ViewDef, type_for_generate::ProductTypeDef}; use spacetimedb_schema::{ @@ -99,31 +99,20 @@ pub(super) fn is_reducer_invokable(reducer: &ReducerDef) -> bool { reducer.lifecycle.is_none() } -/// Iterate over all the [`ReducerDef`]s defined by the module, in alphabetical order by name. -/// -/// Skipping the `init` reducer and internal [`FunctionVisibiity::Internal`] reducers because -/// they should not be directly invokable. -/// Sorting is not necessary for reducers because they are already stored in an IndexMap. +/// Non-lifecycle reducer entry points in declaration order. Default clients see +/// only public functions; IncludePrivate adds Private and Internal methods. pub(super) fn iter_reducers(module: &ModuleDef, visibility: CodegenVisibility) -> impl Iterator { module .reducers() - // `RawModuleDefV10` already marks all lifecycle reducers as private, but we keep - // this filter for backward compatibility with older versions where `init` - // reducers were not private. - .filter(|reducer| reducer.lifecycle != Some(Lifecycle::Init)) - // Prior to `RawModuleDefV10`, all reducers were public by default. Filtering out - // internal reducers here does not break SDKs built against older versions. + .filter(|reducer| reducer.lifecycle.is_none()) .filter(move |reducer| match visibility { CodegenVisibility::IncludePrivate => true, - CodegenVisibility::OnlyPublic => !reducer.visibility.is_private(), + CodegenVisibility::OnlyPublic => reducer.visibility.is_client_callable(), }) } -/// Iterate over all the [`ProcedureDef`]s defined by the module, in alphabetical order by name. -/// -/// Skipping internal [`FunctionVisibiity::Internal`] procedures because they should not be -/// directly invokable. -/// Sorting is necessary to have deterministic reproducible codegen. +/// Procedure entry points in alphabetical order. Default clients see only Public +/// functions; IncludePrivate also generates Private and Internal methods. pub(super) fn iter_procedures( module: &ModuleDef, visibility: CodegenVisibility, @@ -133,7 +122,7 @@ pub(super) fn iter_procedures( .sorted_by_key(|procedure| &procedure.name) .filter(move |procedure| match visibility { CodegenVisibility::IncludePrivate => true, - CodegenVisibility::OnlyPublic => !procedure.visibility.is_private(), + CodegenVisibility::OnlyPublic => procedure.visibility.is_client_callable(), }) } @@ -223,3 +212,64 @@ pub(super) fn iter_constraints(table: &TableDef) -> impl Iterator impl Iterator { module.types().sorted_by_key(|table| &table.accessor_name) } + +#[cfg(test)] +mod visibility_tests { + use super::*; + use spacetimedb_lib::db::raw_def::{ + v10::{FunctionVisibility, RawModuleDefV10Builder}, + v9::Lifecycle, + }; + use spacetimedb_lib::{AlgebraicType, ProductType}; + + #[test] + fn public_codegen_excludes_internal_private_and_every_lifecycle() { + let mut builder = RawModuleDefV10Builder::new(); + builder.add_reducer("ordinary", ProductType::unit()); + for (name, visibility) in [ + ("public_function", FunctionVisibility::ClientCallable), + ("private_function", FunctionVisibility::Private), + ("internal_function", FunctionVisibility::Internal), + ] { + builder.add_reducer_with_visibility(name, ProductType::unit(), Some(visibility)); + builder.add_procedure_with_visibility( + format!("{name}_procedure"), + ProductType::unit(), + AlgebraicType::unit(), + Some(visibility), + ); + } + for (name, lifecycle) in [ + ("init", Lifecycle::Init), + ("connect", Lifecycle::OnConnect), + ("disconnect", Lifecycle::OnDisconnect), + ] { + builder.add_lifecycle_reducer(lifecycle, name, ProductType::unit()); + } + let module: ModuleDef = builder.finish().try_into().unwrap(); + let names = |visibility| { + iter_reducers(&module, visibility) + .map(|r| &r.name[..]) + .collect::>() + }; + assert_eq!(names(CodegenVisibility::OnlyPublic), ["ordinary", "public_function"]); + assert_eq!( + names(CodegenVisibility::IncludePrivate), + ["ordinary", "public_function", "private_function", "internal_function"] + ); + let names = |visibility| { + iter_procedures(&module, visibility) + .map(|p| &p.name[..]) + .collect::>() + }; + assert_eq!(names(CodegenVisibility::OnlyPublic), ["public_function_procedure"]); + assert_eq!( + names(CodegenVisibility::IncludePrivate), + [ + "internal_function_procedure", + "private_function_procedure", + "public_function_procedure" + ] + ); + } +} diff --git a/crates/core/src/host/host_controller.rs b/crates/core/src/host/host_controller.rs index ff017b733c8..fad3d0a1e7d 100644 --- a/crates/core/src/host/host_controller.rs +++ b/crates/core/src/host/host_controller.rs @@ -55,6 +55,9 @@ use tokio::sync::{watch, OwnedRwLockReadGuard, OwnedRwLockWriteGuard, RwLock as use tokio::time::error::Elapsed; use tokio::time::{interval_at, timeout, Instant}; +#[cfg(test)] +mod invocation_flags_tests; + // TODO: // // - [db::Config] should be per-[Database] diff --git a/crates/core/src/host/host_controller/invocation_flags_tests.rs b/crates/core/src/host/host_controller/invocation_flags_tests.rs new file mode 100644 index 00000000000..a451cf00dc7 --- /dev/null +++ b/crates/core/src/host/host_controller/invocation_flags_tests.rs @@ -0,0 +1,139 @@ +//! Run actual V8 hosts without a network or external service. +use super::*; +use crate::db::persistence::LocalPersistenceProvider; +use crate::host::module_host::CallProcedureParams; +use crate::host::{ArgsTuple, FunctionArgs}; +use spacetimedb_lib::db::raw_def::{v10::FunctionVisibility, v10::RawModuleDefV10Builder, v9::Lifecycle}; +use spacetimedb_paths::FromPathUnchecked; +use spacetimedb_primitives::ProcedureId; +use spacetimedb_sats::{AlgebraicType, ProductType}; + +fn program() -> Program { + let mut schema = RawModuleDefV10Builder::new(); + schema.add_lifecycle_reducer(Lifecycle::Init, "init", ProductType::unit()); + schema.add_reducer("external", ProductType::unit()); + schema.add_reducer_with_visibility("internal", ProductType::unit(), Some(FunctionVisibility::Internal)); + schema.add_reducer_with_visibility("private", ProductType::unit(), Some(FunctionVisibility::Private)); + schema.add_procedure("external_procedure", ProductType::unit(), AlgebraicType::U8); + schema.add_procedure_with_visibility( + "internal_procedure", + ProductType::unit(), + AlgebraicType::U8, + Some(FunctionVisibility::Internal), + ); + let schema = spacetimedb_lib::bsatn::to_vec(&spacetimedb_lib::RawModuleDef::V10(schema.finish())).unwrap(); + Program::from_bytes( + ModuleKind::JS, + format!( + r#" + import {{ register_hooks }} from "spacetime:sys@1.0"; + import {{ register_hooks as register_procedures }} from "spacetime:sys@1.2"; + import {{ get_call_auth_flags }} from "spacetime:sys@2.2"; + register_hooks({{ + __describe_module__: function() {{ return new Uint8Array({schema:?}); }}, + __call_reducer__: function(id) {{ + const expected = id === 0 ? 1 : 0; + if (get_call_auth_flags() !== expected) {{ throw new Error("incorrect invocation flags"); }} + return {{ tag: "ok" }}; + }}, + }}); + register_procedures({{ __call_procedure__: function() {{ + return new Uint8Array([get_call_auth_flags()]); + }} }}); + "# + ) + .into_bytes(), + ) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn invocation_flags_are_host_owned_and_internal_visibility_is_enforced() { + let directory = tempfile::tempdir().unwrap(); + let data = Arc::new(ServerDataDir::from_path_unchecked(directory.path().to_owned())); + let program = program(); + let initial = program.clone(); + let storage = move |hash| { + let program = initial.clone(); + async move { Ok((program.hash == hash).then_some(program.bytes)) } + }; + let controller = HostController::new( + data.clone(), + db::Config { + storage: db::Storage::Memory, + page_pool_max_size: None, + }, + HostRuntimeConfig::default(), + Arc::new(storage), + Arc::new(NullEnergyMonitor), + Arc::new(LocalPersistenceProvider::new(data)), + JobCores::without_pinned_cores(), + ); + let database = Database { + id: 0xab10, + database_identity: Identity::from_u256(0xab10u64.into()), + owner_identity: Identity::ONE, + host_type: HostType::Js, + initial_program: program.hash, + }; + // The init reducer itself asserts flags=1, so successful construction also + // verifies the real host-to-JS syscall path for a trusted lifecycle call. + let module = controller + .get_or_launch_module_host(database.clone(), database.id) + .await + .unwrap(); + for sender in [database.owner_identity, database.database_identity, Identity::ZERO] { + module + .call_reducer(sender, None, None, None, None, "external", FunctionArgs::Nullary) + .await + .unwrap() + .outcome + .into_result() + .unwrap(); + for name in ["internal", "init"] { + assert!(module + .call_reducer(sender, None, None, None, None, name, FunctionArgs::Nullary) + .await + .is_err()); + } + let result = module + .call_procedure(sender, None, None, "external_procedure", FunctionArgs::Nullary) + .await; + assert_eq!(result.result.unwrap().return_val, AlgebraicValue::U8(0)); + assert!(module + .call_procedure(sender, None, None, "internal_procedure", FunctionArgs::Nullary) + .await + .result + .is_err()); + assert_eq!( + module + .call_reducer(sender, None, None, None, None, "private", FunctionArgs::Nullary) + .await + .is_ok(), + sender == database.owner_identity, + ); + } + // Trusted host work uses its explicit constructor. An ordinary later call + // observes zero again even if the procedure instance is reused. + let result = module + .call_procedure_with_params( + "internal_procedure", + CallProcedureParams::from_system( + Timestamp::now(), + database.database_identity, + ProcedureId(1), + ArgsTuple::nullary(), + ), + ) + .await + .unwrap(); + assert_eq!(result.result.unwrap().return_val, AlgebraicValue::U8(1)); + let result = module + .call_procedure(Identity::ONE, None, None, "external_procedure", FunctionArgs::Nullary) + .await; + assert_eq!(result.result.unwrap().return_val, AlgebraicValue::U8(0)); + drop(module); + controller + .exit_module_host(database.id, Duration::from_secs(5)) + .await + .unwrap(); +} diff --git a/crates/core/src/host/instance_env.rs b/crates/core/src/host/instance_env.rs index 1bfb4f7f7c7..42262c991c0 100644 --- a/crates/core/src/host/instance_env.rs +++ b/crates/core/src/host/instance_env.rs @@ -54,6 +54,7 @@ pub struct InstanceEnv { in_anon_tx: bool, /// A procedure's last known transaction offset. procedure_last_tx_offset: Option, + call_auth_flags: u32, } /// `InstanceEnv` needs to be `Send` because it is created on the host thread @@ -238,6 +239,7 @@ impl InstanceEnv { func_name: None, in_anon_tx: false, procedure_last_tx_offset: None, + call_auth_flags: 0, } } @@ -252,6 +254,15 @@ impl InstanceEnv { self.start_instant = Instant::now(); self.func_type = func_type; self.func_name = Some(name); + self.call_auth_flags = 0; + } + + pub(crate) fn set_call_auth_flags(&mut self, flags: u32) { + self.call_auth_flags = flags; + } + + pub(crate) fn get_call_auth_flags(&self) -> u32 { + self.call_auth_flags } /// Returns the name of the most recent reducer to be run in this environment, diff --git a/crates/core/src/host/mod.rs b/crates/core/src/host/mod.rs index f28a515c910..06e19c367b9 100644 --- a/crates/core/src/host/mod.rs +++ b/crates/core/src/host/mod.rs @@ -192,6 +192,7 @@ pub enum AbiCall { JwtLength, GetJwt, EnvGet, + GetCallAuthFlags, VolatileNonatomicScheduleImmediate, diff --git a/crates/core/src/host/module_host.rs b/crates/core/src/host/module_host.rs index 25eb09e6382..4089b88ec5d 100644 --- a/crates/core/src/host/module_host.rs +++ b/crates/core/src/host/module_host.rs @@ -812,6 +812,7 @@ pub struct CallReducerParams { pub timestamp: Timestamp, pub caller_identity: Identity, pub caller_connection_id: ConnectionId, + pub(crate) call_auth_flags: u32, pub client: Option>, pub request_id: Option, pub timer: Option, @@ -832,6 +833,7 @@ impl CallReducerParams { timestamp, caller_identity, caller_connection_id: ConnectionId::ZERO, + call_auth_flags: 1, client: None, request_id: None, timer: None, @@ -1211,6 +1213,7 @@ pub struct CallProcedureParams { pub timestamp: Timestamp, pub caller_identity: Identity, pub caller_connection_id: ConnectionId, + pub(crate) call_auth_flags: u32, pub timer: Option, pub procedure_id: ProcedureId, pub args: ArgsTuple, @@ -1229,6 +1232,7 @@ impl CallProcedureParams { timestamp, caller_identity, caller_connection_id: ConnectionId::ZERO, + call_auth_flags: 1, timer: None, procedure_id, args, @@ -2299,6 +2303,7 @@ impl ModuleHost { timestamp: Timestamp::now(), caller_identity, caller_connection_id, + call_auth_flags: 0, client, request_id, timer, @@ -2326,7 +2331,10 @@ impl ModuleHost { return Err(ReducerCallError::LifecycleReducer(lifecycle)); } - if reducer_def.visibility.is_private() && !self.is_database_owner(caller_identity) { + if !reducer_def + .visibility + .allows_invocation(false, self.is_database_owner(caller_identity)) + { return Err(ReducerCallError::NoSuchReducer); } @@ -2836,7 +2844,10 @@ impl ModuleHost { .procedure_by_name_with_module(procedure_name) .ok_or(ProcedureCallError::NoSuchProcedure)?; - if procedure_def.visibility.is_private() && !self.is_database_owner(caller_identity) { + if !procedure_def + .visibility + .allows_invocation(false, self.is_database_owner(caller_identity)) + { return Err(ProcedureCallError::NoSuchProcedure); } @@ -2851,6 +2862,7 @@ impl ModuleHost { timestamp: Timestamp::now(), caller_identity, caller_connection_id, + call_auth_flags: 0, timer, procedure_id, args, diff --git a/crates/core/src/host/v8/mod.rs b/crates/core/src/host/v8/mod.rs index 6f357962bbb..564e7db862d 100644 --- a/crates/core/src/host/v8/mod.rs +++ b/crates/core/src/host/v8/mod.rs @@ -1988,6 +1988,7 @@ where // Start the timer. // We'd like this tightly around `call`. env.start_funcall(op.name().clone(), op.timestamp(), op.call_type()); + env.instance_env.set_call_auth_flags(op.call_auth_flags()); // Wrap the call in `TryCatch`. // @@ -2113,6 +2114,7 @@ mod test { name: &ReducerName::for_test("foobar"), caller_identity: &Identity::ONE, caller_connection_id: &ConnectionId::ZERO, + call_auth_flags: 0, timestamp: Timestamp::from_micros_since_unix_epoch(24), args: &ArgsTuple::nullary(), }; diff --git a/crates/core/src/host/v8/syscall/common.rs b/crates/core/src/host/v8/syscall/common.rs index 52b78463def..29aedea9b7b 100644 --- a/crates/core/src/host/v8/syscall/common.rs +++ b/crates/core/src/host/v8/syscall/common.rs @@ -44,6 +44,7 @@ pub fn call_call_procedure( name: _, caller_identity: sender, caller_connection_id: connection_id, + call_auth_flags: _, timestamp, arg_bytes: procedure_args, } = op; diff --git a/crates/core/src/host/v8/syscall/mod.rs b/crates/core/src/host/v8/syscall/mod.rs index 029d5836282..2d5e74eb862 100644 --- a/crates/core/src/host/v8/syscall/mod.rs +++ b/crates/core/src/host/v8/syscall/mod.rs @@ -62,8 +62,8 @@ fn resolve_sys_module_inner<'scope>( (1, 3) => Ok(v1::sys_v1_3(scope)), (2, 0) => Ok(v2::sys_v2_0(scope)), (2, 1) => Ok(v2::sys_v2_1(scope)), - // sys2.2 is reserved for invocation authority. (2, 3) => Ok(v2::sys_v2_3(scope)), + (2, 2) => Ok(v2::sys_v2_2(scope)), _ => Err(TypeError(format!( "Could not import {spec:?}, likely because this module was built for a newer version of SpacetimeDB.\n\ It requires sys module v{major}.{minor}, but that version is not supported by the database." diff --git a/crates/core/src/host/v8/syscall/v1.rs b/crates/core/src/host/v8/syscall/v1.rs index f3aea9f6c52..4d6783465df 100644 --- a/crates/core/src/host/v8/syscall/v1.rs +++ b/crates/core/src/host/v8/syscall/v1.rs @@ -495,6 +495,7 @@ pub(super) fn call_call_reducer( name: _, caller_identity: sender, caller_connection_id: conn_id, + call_auth_flags: _, timestamp, args: reducer_args, } = op; diff --git a/crates/core/src/host/v8/syscall/v2.rs b/crates/core/src/host/v8/syscall/v2.rs index 8fb376ec7af..95783ddddb6 100644 --- a/crates/core/src/host/v8/syscall/v2.rs +++ b/crates/core/src/host/v8/syscall/v2.rs @@ -169,6 +169,18 @@ pub(super) fn sys_v2_1<'scope>(scope: &mut PinScope<'scope, '_>) -> Local<'scope ) } +pub(super) fn sys_v2_2<'scope>(scope: &mut PinScope<'scope, '_>) -> Local<'scope, Module> { + create_synthetic_module!( + scope, + "spacetime:sys@2.2", + (with_sys_result, AbiCall::GetCallAuthFlags, get_call_auth_flags), + ) +} + +fn get_call_auth_flags(scope: &mut PinScope<'_, '_>, _args: FunctionCallbackArguments<'_>) -> SysCallResult { + Ok(get_env(scope)?.instance_env.get_call_auth_flags()) +} + pub(super) fn sys_v2_3<'scope>(scope: &mut PinScope<'scope, '_>) -> Local<'scope, Module> { create_synthetic_module!(scope, "spacetime:sys@2.3", (with_sys_result, AbiCall::EnvGet, env_get),) } @@ -467,6 +479,7 @@ pub(super) fn call_call_reducer<'scope>( name: _, caller_identity: sender, caller_connection_id: conn_id, + call_auth_flags: _, timestamp, args: reducer_args, } = op; diff --git a/crates/core/src/host/wasm_common.rs b/crates/core/src/host/wasm_common.rs index dc8baa44227..db0535fca59 100644 --- a/crates/core/src/host/wasm_common.rs +++ b/crates/core/src/host/wasm_common.rs @@ -444,8 +444,8 @@ macro_rules! abi_funcs { "spacetime_10.4"::datastore_delete_by_index_scan_point_bsatn, "spacetime_10.5"::datastore_clear, - // ABI10.6 is reserved for invocation authority. "spacetime_10.7"::env_get, + "spacetime_10.6"::get_call_auth_flags, } $link_async! { 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 aee984fe3a5..95df05f3616 100644 --- a/crates/core/src/host/wasm_common/module_host_actor.rs +++ b/crates/core/src/host/wasm_common/module_host_actor.rs @@ -782,6 +782,7 @@ impl InstanceCommon { timestamp, caller_identity, caller_connection_id, + call_auth_flags, timer, procedure_id, args, @@ -801,6 +802,7 @@ impl InstanceCommon { name: procedure_name.clone().into(), caller_identity, caller_connection_id, + call_auth_flags, timestamp, arg_bytes: args.get_bsatn().clone(), }; @@ -965,6 +967,7 @@ impl InstanceCommon { timestamp, caller_identity, caller_connection_id, + call_auth_flags, client, request_id, reducer_id, @@ -988,6 +991,7 @@ impl InstanceCommon { name: reducer_name, caller_identity: &caller_identity, caller_connection_id: &caller_connection_id, + call_auth_flags, timestamp, args: &args, }; @@ -1853,6 +1857,9 @@ pub trait InstanceOp { fn name(&self) -> &NamespacedIdentifier; fn timestamp(&self) -> Timestamp; fn call_type(&self) -> FuncCallType; + fn call_auth_flags(&self) -> u32 { + 0 + } } /// Describes a view call in a cheaply shareable way. @@ -1913,6 +1920,7 @@ pub struct ReducerOp<'a> { pub name: &'a ReducerName, pub caller_identity: &'a Identity, pub caller_connection_id: &'a ConnectionId, + pub call_auth_flags: u32, pub timestamp: Timestamp, /// The arguments passed to the reducer. pub args: &'a ArgsTuple, @@ -1928,6 +1936,9 @@ impl InstanceOp for ReducerOp<'_> { fn call_type(&self) -> FuncCallType { FuncCallType::Reducer } + fn call_auth_flags(&self) -> u32 { + self.call_auth_flags + } } impl From> for execution_context::ReducerContext { @@ -1937,6 +1948,7 @@ impl From> for execution_context::ReducerContext { name, caller_identity, caller_connection_id, + call_auth_flags: _, timestamp, args, }: ReducerOp<'_>, @@ -1958,6 +1970,7 @@ pub struct ProcedureOp { pub name: NamespacedIdentifier, pub caller_identity: Identity, pub caller_connection_id: ConnectionId, + pub call_auth_flags: u32, pub timestamp: Timestamp, pub arg_bytes: Bytes, } @@ -1972,6 +1985,9 @@ impl InstanceOp for ProcedureOp { fn call_type(&self) -> FuncCallType { FuncCallType::Procedure } + fn call_auth_flags(&self) -> u32 { + self.call_auth_flags + } } /// Describes an HTTP handler call in a cheaply shareable way. diff --git a/crates/core/src/host/wasmtime/wasm_instance_env.rs b/crates/core/src/host/wasmtime/wasm_instance_env.rs index 23d33983440..c35514864da 100644 --- a/crates/core/src/host/wasmtime/wasm_instance_env.rs +++ b/crates/core/src/host/wasmtime/wasm_instance_env.rs @@ -338,6 +338,14 @@ impl WasmInstanceEnv { self.bytes_sinks.remove(&sink).unwrap_or_default() } + pub fn get_call_auth_flags(caller: Caller<'_, Self>) -> u32 { + caller.data().instance_env.get_call_auth_flags() + } + + pub(crate) fn set_call_auth_flags(&mut self, flags: u32) { + self.instance_env.set_call_auth_flags(flags); + } + /// Signal to this `WasmInstanceEnv` that a reducer or procedure call is beginning. /// /// Returns the handle used by reducers and procedures to read from `args` diff --git a/crates/core/src/host/wasmtime/wasmtime_module.rs b/crates/core/src/host/wasmtime/wasmtime_module.rs index a5316ceb95c..57ed3416ffa 100644 --- a/crates/core/src/host/wasmtime/wasmtime_module.rs +++ b/crates/core/src/host/wasmtime/wasmtime_module.rs @@ -647,6 +647,7 @@ impl module_host_actor::WasmInstance for WasmtimeInstance { store .data_mut() .start_funcall(reducer_name, args_bytes, op.timestamp, op.call_type()); + store.data_mut().set_call_auth_flags(op.call_auth_flags); let call_result = call_sync_typed_func( &self.call_reducer, @@ -770,6 +771,7 @@ impl module_host_actor::WasmInstance for WasmtimeInstance { store .data_mut() .start_funcall(op.name().clone(), op.arg_bytes, op.timestamp, FuncCallType::Procedure); + store.data_mut().set_call_auth_flags(op.call_auth_flags); let Some(call_procedure) = self.call_procedure.as_ref() else { let res = module_host_actor::ProcedureExecuteResult { diff --git a/crates/lib/src/db/raw_def/v10.rs b/crates/lib/src/db/raw_def/v10.rs index b21d8470b84..38971f84001 100644 --- a/crates/lib/src/db/raw_def/v10.rs +++ b/crates/lib/src/db/raw_def/v10.rs @@ -101,6 +101,9 @@ pub enum RawModuleDefV10Section { /// Submodules, keyed by the namespace they are registered under. Submodules(Vec), + /// Module bindings capabilities, independent of function visibility. + /// Older hosts reject this section instead of silently ignoring its requirements. + Capabilities(Vec), } #[derive(Debug, Clone, SpacetimeType)] @@ -331,6 +334,9 @@ pub struct RawReducerDefV10 { } /// The visibility of a function (reducer or procedure). +/// +/// New variants MUST be appended to preserve existing BSATN tags. Older hosts +/// reject unknown tags, so new restrictions cannot be silently discarded. #[derive(Debug, Copy, Clone, SpacetimeType)] #[sats(crate = crate)] #[cfg_attr(feature = "test", derive(PartialEq, Eq, PartialOrd, Ord))] @@ -340,11 +346,31 @@ pub enum FunctionVisibility { /// Still callable by the module owner, collaborators, /// and internal module code. /// - /// Enabled for lifecycle reducers and scheduled functions by default. + /// The default for scheduled functions. Older lifecycle definitions also use + /// this tag; lifecycle assignments always enforce host-event-only invocation. Private, - /// Callable from client code. + /// Callable from client code, with the historical contextual defaults. + /// Scheduled functions become Private; lifecycle reducers remain host event handlers. ClientCallable, + + /// Callable only by a host-verified internal invocation. + Internal, + + /// Explicitly callable from client code, including when scheduled. + /// This separate tag preserves the meaning of existing ClientCallable definitions. + ExplicitClientCallable, +} + +impl FunctionVisibility { + /// Encode a source declaration without changing historical contextual defaults. + pub fn from_declaration(declared: Option, default: Self) -> Self { + match declared { + Some(Self::ClientCallable | Self::ExplicitClientCallable) => Self::ExplicitClientCallable, + Some(visibility) => visibility, + None => default, + } + } } /// A schedule definition. @@ -1092,10 +1118,20 @@ impl RawModuleDefV10Builder { /// This is because `SpacetimeType` is not implemented for `ReducerContext`, /// so it can never act like an ordinary argument.) pub fn add_reducer(&mut self, source_name: impl Into, params: ProductType) { + self.add_reducer_with_visibility(source_name, params, None); + } + + /// Add a reducer with an optional explicit visibility declaration. + pub fn add_reducer_with_visibility( + &mut self, + source_name: impl Into, + params: ProductType, + visibility: Option, + ) { self.reducers_mut().push(RawReducerDefV10 { source_name: source_name.into(), params, - visibility: FunctionVisibility::ClientCallable, + visibility: FunctionVisibility::from_declaration(visibility, FunctionVisibility::ClientCallable), ok_return_type: reducer_default_ok_return_type(), err_return_type: reducer_default_err_return_type(), }); @@ -1116,12 +1152,23 @@ impl RawModuleDefV10Builder { source_name: impl Into, params: ProductType, return_type: AlgebraicType, + ) { + self.add_procedure_with_visibility(source_name, params, return_type, None); + } + + /// Add a procedure with an optional explicit visibility declaration. + pub fn add_procedure_with_visibility( + &mut self, + source_name: impl Into, + params: ProductType, + return_type: AlgebraicType, + visibility: Option, ) { self.procedures_mut().push(RawProcedureDefV10 { source_name: source_name.into(), params, return_type, - visibility: FunctionVisibility::ClientCallable, + visibility: FunctionVisibility::from_declaration(visibility, FunctionVisibility::ClientCallable), }) } @@ -1165,6 +1212,19 @@ impl RawModuleDefV10Builder { lifecycle_spec: Lifecycle, function_name: impl Into, params: ProductType, + ) { + self.add_lifecycle_reducer_with_visibility(lifecycle_spec, function_name, params, None); + } + + /// Add a lifecycle reducer with an optional visibility declaration. + /// Source bindings must reject explicit Private or public lifecycle annotations. + /// The raw Private tag remains accepted for compatibility with existing modules. + pub fn add_lifecycle_reducer_with_visibility( + &mut self, + lifecycle_spec: Lifecycle, + function_name: impl Into, + params: ProductType, + visibility: Option, ) { let function_name = function_name.into(); self.lifecycle_reducers_mut().push(RawLifeCycleReducerDefV10 { @@ -1175,7 +1235,7 @@ impl RawModuleDefV10Builder { self.reducers_mut().push(RawReducerDefV10 { source_name: function_name, params, - visibility: FunctionVisibility::Private, + visibility: FunctionVisibility::from_declaration(visibility, FunctionVisibility::Private), ok_return_type: reducer_default_ok_return_type(), err_return_type: reducer_default_err_return_type(), }); @@ -1201,6 +1261,22 @@ impl RawModuleDefV10Builder { }); } + /// Declare a module bindings capability. + pub fn add_capability(&mut self, capability: impl Into) { + if let Some(RawModuleDefV10Section::Capabilities(names)) = self + .module + .sections + .iter_mut() + .find(|section| matches!(section, RawModuleDefV10Section::Capabilities(_))) + { + names.push(capability.into()); + } else { + self.module + .sections + .push(RawModuleDefV10Section::Capabilities(vec![capability.into()])); + } + } + /// Add a row-level security policy to the module. /// /// The `sql` expression should be a valid SQL expression that will be used to filter rows. @@ -1483,3 +1559,141 @@ impl RawTableDefBuilderV10<'_> { .map(|i| ColId(i as u16)) } } + +#[cfg(test)] +mod compatibility_tests { + use super::*; + use crate::{bsatn, RawModuleDef}; + + // Frozen pre-extension wire types. Do not replace the visibility, function, + // or section definitions below with their current counterparts. + #[derive(SpacetimeType)] + #[sats(crate = crate)] + enum LegacyVisibility { + Private, + ClientCallable, + } + + #[derive(SpacetimeType)] + #[sats(crate = crate)] + struct LegacyReducer { + source_name: RawIdentifier, + params: ProductType, + visibility: LegacyVisibility, + ok_return_type: AlgebraicType, + err_return_type: AlgebraicType, + } + + #[derive(SpacetimeType)] + #[sats(crate = crate)] + struct LegacyProcedure { + source_name: RawIdentifier, + params: ProductType, + return_type: AlgebraicType, + visibility: LegacyVisibility, + } + + #[derive(SpacetimeType)] + #[sats(crate = crate)] + enum LegacySection { + Typespace(Typespace), + Types(Vec), + Tables(Vec), + Reducers(Vec), + Procedures(Vec), + Views(Vec), + Schedules(Vec), + LifeCycleReducers(Vec), + RowLevelSecurity(Vec), + CaseConversionPolicy(CaseConversionPolicy), + ExplicitNames(ExplicitNames), + HttpHandlers(Vec), + HttpRoutes(Vec), + } + + #[derive(SpacetimeType)] + #[sats(crate = crate)] + struct LegacyV10 { + sections: Vec, + } + + #[derive(SpacetimeType)] + #[sats(crate = crate)] + enum LegacyModule { + V8BackCompat(crate::RawModuleDefV8), + V9(super::super::v9::RawModuleDefV9), + V10(LegacyV10), + } + + #[test] + fn existing_v10_wire_tags_and_function_products_are_unchanged() { + for (visibility, expected) in [ + (FunctionVisibility::Private, 0), + (FunctionVisibility::ClientCallable, 1), + (FunctionVisibility::Internal, 2), + (FunctionVisibility::ExplicitClientCallable, 3), + ] { + assert_eq!(bsatn::to_vec(&visibility).unwrap(), [expected]); + } + let legacy = LegacyModule::V10(LegacyV10 { + sections: vec![ + LegacySection::Reducers(vec![LegacyReducer { + source_name: "run".into(), + params: ProductType::unit(), + visibility: LegacyVisibility::ClientCallable, + ok_return_type: reducer_default_ok_return_type(), + err_return_type: reducer_default_err_return_type(), + }]), + LegacySection::Procedures(vec![LegacyProcedure { + source_name: "read".into(), + params: ProductType::unit(), + return_type: AlgebraicType::U64, + visibility: LegacyVisibility::Private, + }]), + ], + }); + let bytes = bsatn::to_vec(&legacy).unwrap(); + assert_eq!(bytes[0], 2); + let current: RawModuleDef = bsatn::from_slice(&bytes).unwrap(); + assert_eq!(bsatn::to_vec(¤t).unwrap(), bytes); + let frozen: LegacyModule = bsatn::from_slice(&bsatn::to_vec(¤t).unwrap()).unwrap(); + assert_eq!(bsatn::to_vec(&frozen).unwrap(), bytes); + + assert_eq!( + bsatn::to_vec(&RawModuleDefV10Section::HttpRoutes(vec![])).unwrap(), + [12, 0, 0, 0, 0] + ); + assert_eq!( + bsatn::to_vec(&RawModuleDefV10Section::Capabilities(vec![])).unwrap(), + [15, 0, 0, 0, 0] + ); + } + + #[test] + fn older_hosts_reject_new_visibility_and_capabilities() { + for visibility in [FunctionVisibility::Internal, FunctionVisibility::ExplicitClientCallable] { + for procedure in [false, true] { + let mut builder = RawModuleDefV10Builder::new(); + if procedure { + builder.add_procedure_with_visibility( + "run", + ProductType::unit(), + AlgebraicType::unit(), + Some(visibility), + ); + } else { + builder.add_reducer_with_visibility("run", ProductType::unit(), Some(visibility)); + } + let bytes = bsatn::to_vec(&RawModuleDef::V10(builder.finish())).unwrap(); + assert_eq!(bytes[0], 2); + assert!(bsatn::from_slice::(&bytes).is_err()); + assert!(bsatn::from_slice::(&bytes).is_ok()); + } + } + let mut builder = RawModuleDefV10Builder::new(); + builder.add_capability("hosted_auth_v1"); + let bytes = bsatn::to_vec(&RawModuleDef::V10(builder.finish())).unwrap(); + assert!(bsatn::from_slice::(&bytes).is_err()); + assert!(bsatn::from_slice::(&bytes).is_ok()); + } +} diff --git a/crates/schema/src/auto_migrate.rs b/crates/schema/src/auto_migrate.rs index 97fff428830..db728fcd250 100644 --- a/crates/schema/src/auto_migrate.rs +++ b/crates/schema/src/auto_migrate.rs @@ -220,6 +220,31 @@ pub struct AutoMigratePlan<'def> { } impl AutoMigratePlan<'_> { + /// Function authority changes include every namespace in the published API. + pub fn function_visibility_changes( + &self, + ) -> impl Iterator { + let reducers = self + .old + .all_reducers_with_prefix() + .into_iter() + .filter(|(_, _, old)| old.lifecycle.is_none()) + .filter_map(|(prefix, _, old)| { + let name = format!("{prefix}{}", old.name); + let (_, new) = self.new.reducer_by_name(&name)?; + (old.visibility != new.visibility).then_some((name, &old.visibility, &new.visibility)) + }); + let procedures = self + .old + .all_procedures_with_prefix() + .into_iter() + .filter_map(|(prefix, _, old)| { + let name = format!("{prefix}{}", old.name); + let (_, new) = self.new.procedure_by_name(&name)?; + (old.visibility != new.visibility).then_some((name, &old.visibility, &new.visibility)) + }); + reducers.chain(procedures) + } fn any_step(&self, f: impl Fn(&AutoMigrateStep<'_>) -> bool) -> bool { self.steps.iter().any(f) } @@ -493,6 +518,15 @@ pub fn ponder_auto_migrate<'def>(old: &'def ModuleDef, new: &'def ModuleDef) -> prechecks: Vec::new(), }; + let restricts_function_access = plan.function_visibility_changes().any(|(_, old, new)| { + [false, true] + .into_iter() + .any(|owner| old.allows_invocation(false, owner) && !new.allows_invocation(false, owner)) + }); + if restricts_function_access { + plan.ensure_disconnect_all_users(); + } + let views_ok = auto_migrate_views(&mut plan); let tables_ok = auto_migrate_tables(&mut plan); @@ -2909,6 +2943,33 @@ mod tests { raw.try_into().expect("should be a valid module definition") } + #[test] + fn submodule_visibility_restrictions_disconnect_and_report_qualified_names() { + use spacetimedb_lib::db::raw_def::v10::FunctionVisibility as RawVisibility; + let module = |visibility| { + create_module_def_with_submodules( + |_| {}, + vec![make_submodule("lib", |builder| { + builder.add_reducer_with_visibility("job", ProductType::unit(), Some(visibility)); + builder.add_procedure_with_visibility( + "read", + ProductType::unit(), + AlgebraicType::U8, + Some(visibility), + ); + })], + ) + }; + let old = module(RawVisibility::ExplicitClientCallable); + let restricted = module(RawVisibility::Internal); + let plan = ponder_auto_migrate(&old, &restricted).unwrap(); + assert!(plan.steps.contains(&AutoMigrateStep::DisconnectAllUsers)); + let names: Vec<_> = plan.function_visibility_changes().map(|(name, _, _)| name).collect(); + assert_eq!(names, ["lib.job", "lib.read"]); + let relaxed = ponder_auto_migrate(&restricted, &old).unwrap(); + assert!(!relaxed.steps.contains(&AutoMigrateStep::DisconnectAllUsers)); + } + #[test] fn submodule_table_unchanged() { let submodule = || { diff --git a/crates/schema/src/auto_migrate/formatter.rs b/crates/schema/src/auto_migrate/formatter.rs index 7d079f04a62..6c684121ca8 100644 --- a/crates/schema/src/auto_migrate/formatter.rs +++ b/crates/schema/src/auto_migrate/formatter.rs @@ -20,6 +20,9 @@ use thiserror::Error; pub fn format_plan(f: &mut F, plan: &AutoMigratePlan) -> Result<(), FormattingErrors> { f.format_header()?; + for (name, old, new) in plan.function_visibility_changes() { + f.format_function_visibility(&name, old, new)?; + } for step in &plan.steps { format_step(f, step, plan)?; @@ -180,6 +183,12 @@ pub enum Action { /// It allows for different implementations, such as ANSI formatting or plain text formatting. pub trait MigrationFormatter { fn format_header(&mut self) -> io::Result<()>; + fn format_function_visibility( + &mut self, + name: &str, + old: &crate::def::FunctionVisibility, + new: &crate::def::FunctionVisibility, + ) -> io::Result<()>; fn format_add_table(&mut self, table_info: &TableInfo) -> io::Result<()>; fn format_remove_table(&mut self, table_name: &NamespacedIdentifier) -> io::Result<()>; fn format_view(&mut self, view_info: &ViewInfo, action: Action) -> io::Result<()>; diff --git a/crates/schema/src/auto_migrate/termcolor_formatter.rs b/crates/schema/src/auto_migrate/termcolor_formatter.rs index 811c04b1860..ab27935cddf 100644 --- a/crates/schema/src/auto_migrate/termcolor_formatter.rs +++ b/crates/schema/src/auto_migrate/termcolor_formatter.rs @@ -157,6 +157,15 @@ impl TermColorFormatter { } impl MigrationFormatter for TermColorFormatter { + fn format_function_visibility( + &mut self, + name: &str, + old: &crate::def::FunctionVisibility, + new: &crate::def::FunctionVisibility, + ) -> io::Result<()> { + self.write_bullet(&format!("Function {name} visibility: {old} -> {new}")) + } + fn format_header(&mut self) -> io::Result<()> { let line = "━".repeat(60); self.write_line(&line)?; diff --git a/crates/schema/src/def.rs b/crates/schema/src/def.rs index 521bcb35aa9..3e2dd67762e 100644 --- a/crates/schema/src/def.rs +++ b/crates/schema/src/def.rs @@ -15,7 +15,7 @@ //! After validation, a `ModuleDef` can be converted to the `*Schema` types in `crate::schema` for use in the database. //! (Eventually, we may unify these types...) -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::fmt::{self, Debug, Write}; use std::hash::Hash; @@ -179,6 +179,8 @@ pub struct ModuleDef { /// Submodules, keyed by the namespace they are registered under. submodules: IndexMap, + /// Validated module bindings capabilities. Legacy modules have none. + capabilities: BTreeSet, } #[derive(Debug, Clone, Copy, Eq, PartialEq)] @@ -190,6 +192,14 @@ pub enum RawModuleDefVersion { } impl ModuleDef { + pub fn supports_hosted_auth_v1(&self) -> bool { + self.capabilities.contains(&RawIdentifier::new("hosted_auth_v1")) + } + + pub fn capabilities(&self) -> impl Iterator { + self.capabilities.iter() + } + /// The raw module definition version this module was authored under. pub fn raw_module_def_version(&self) -> RawModuleDefVersion { self.raw_module_def_version @@ -205,6 +215,21 @@ impl ModuleDef { self.tables.values() } + /// The row type of a table or view, addressed by its canonical name. + pub fn type_ref_for_table_like(&self, name: &str) -> Option { + self.table(name) + .map(|table| table.product_type_ref) + .or_else(|| self.view(name).map(|view| view.product_type_ref)) + } + + /// Serialize without reinterpreting the definition's original version semantics. + pub fn into_raw(self) -> RawModuleDef { + match self.raw_module_def_version { + RawModuleDefVersion::V9OrEarlier => RawModuleDef::V9(self.try_into().expect("same-version conversion")), + RawModuleDefVersion::V10 => RawModuleDef::V10(self.into()), + } + } + /// The indexes of the module definition. pub fn indexes(&self) -> impl Iterator { self.tables().flat_map(|table| table.indexes.values()) @@ -944,7 +969,7 @@ impl TryFrom for ModuleDef { RawModuleDef::V8BackCompat(v8_mod) => Self::try_from(v8_mod), RawModuleDef::V9(v9_mod) => Self::try_from(v9_mod), RawModuleDef::V10(v10_mod) => Self::try_from(v10_mod), - _ => unimplemented!(), + _ => Err(crate::error::ValidationError::UnsupportedModuleVersion.into()), } } } @@ -964,11 +989,14 @@ impl TryFrom for ModuleDef { validate::v9::validate(v9_mod) } } -/// Note: this conversion is lossy for modules with submodules. `RawModuleDefV9` has no -/// submodule representation, so submodules (and everything defined in them) are dropped. -/// Callers serving V9 to old clients should be aware those clients see a partial module. -impl From for RawModuleDefV9 { - fn from(val: ModuleDef) -> Self { +impl TryFrom for RawModuleDefV9 { + type Error = SchemaConversionError; + fn try_from(val: ModuleDef) -> Result { + if val.raw_module_def_version != RawModuleDefVersion::V9OrEarlier { + return Err(SchemaConversionError { + target: RawModuleDefVersion::V9OrEarlier, + }); + } let ModuleDef { path: _, tables, @@ -986,6 +1014,7 @@ impl From for RawModuleDefV9 { http_routes: _, raw_module_def_version: _, submodules: _, + capabilities: _, } = val; // Extract column defaults from tables before consuming tables @@ -1004,18 +1033,26 @@ impl From for RawModuleDefV9 { }) .collect(); - RawModuleDefV9 { + let raw_reducers = reducers + .into_values() + .map(TryInto::try_into) + .collect::>()?; + let raw_procedures = procedures + .into_values() + .map(TryInto::try_into) + .collect::, _>>()?; + Ok(RawModuleDefV9 { tables: to_raw(tables), - reducers: reducers.into_iter().map(|(_, def)| def.into()).collect(), + reducers: raw_reducers, types: to_raw(types), misc_exports: column_defaults .into_iter() - .chain(procedures.into_iter().map(|(_, def)| def.into())) + .chain(raw_procedures) .chain(views.into_iter().map(|(_, def)| def.into())) .collect(), typespace, row_level_security: row_level_security_raw.into_iter().map(|(_, def)| def).collect(), - } + }) } } @@ -1046,6 +1083,7 @@ impl From for RawModuleDefV10 { http_routes, raw_module_def_version: _, submodules, + capabilities, } = val; let mut sections = Vec::new(); @@ -1106,7 +1144,15 @@ impl From for RawModuleDefV10 { RawIdentifier::from(rd.accessor_name.clone()), RawIdentifier::from(rd.name.local().clone()), ); - rd.into() + let public_scheduled = rd.visibility.is_client_callable() + && schedules + .iter() + .any(|schedule| schedule.function_name == RawIdentifier::from(rd.name.clone())); + let mut raw: RawReducerDefV10 = rd.into(); + if public_scheduled { + raw.visibility = RawFunctionVisibility::ExplicitClientCallable; + } + raw }) .collect(); if !raw_reducers.is_empty() { @@ -1121,7 +1167,15 @@ impl From for RawModuleDefV10 { RawIdentifier::from(pd.accessor_name.clone()), RawIdentifier::from(pd.name.clone()), ); - pd.into() + let public_scheduled = pd.visibility.is_client_callable() + && schedules + .iter() + .any(|schedule| schedule.function_name == RawIdentifier::from(pd.name.clone())); + let mut raw: RawProcedureDefV10 = pd.into(); + if public_scheduled { + raw.visibility = RawFunctionVisibility::ExplicitClientCallable; + } + raw }) .collect(); if !raw_procedures.is_empty() { @@ -1216,6 +1270,9 @@ impl From for RawModuleDefV10 { sections.push(RawModuleDefV10Section::Submodules(submodules)); } + if !capabilities.is_empty() { + sections.push(RawModuleDefV10Section::Capabilities(capabilities.into_iter().collect())); + } RawModuleDefV10 { sections } } } @@ -2279,9 +2336,36 @@ pub enum FunctionVisibility { /// Callable from client code. ClientCallable, + + /// Callable only by a host-verified internal invocation. + Internal, +} + +impl fmt::Display for FunctionVisibility { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Private => "Private", + Self::ClientCallable => "Public", + Self::Internal => "Internal", + }) + } } impl FunctionVisibility { + pub fn is_client_callable(&self) -> bool { + matches!(self, Self::ClientCallable) + } + pub fn is_internal(&self) -> bool { + matches!(self, Self::Internal) + } + /// Lifecycle event dispatch is a separate restriction from this predicate. + pub fn allows_invocation(&self, is_internal: bool, is_authorized_private_caller: bool) -> bool { + match self { + Self::Internal => is_internal, + Self::Private => is_internal || is_authorized_private_caller, + Self::ClientCallable => true, + } + } pub fn is_private(&self) -> bool { matches!(self, FunctionVisibility::Private) } @@ -2292,16 +2376,26 @@ impl From for FunctionVisibility { fn from(val: RawFunctionVisibility) -> Self { match val { RawFunctionVisibility::Private => FunctionVisibility::Private, - RawFunctionVisibility::ClientCallable => FunctionVisibility::ClientCallable, + RawFunctionVisibility::ClientCallable | RawFunctionVisibility::ExplicitClientCallable => { + FunctionVisibility::ClientCallable + } + RawFunctionVisibility::Internal => FunctionVisibility::Internal, } } } +#[derive(Debug, Clone, thiserror::Error)] +#[error("schema cannot be represented as {target:?} without losing function visibility or source-version semantics; request schema version 10")] +pub struct SchemaConversionError { + pub target: RawModuleDefVersion, +} + impl From for RawFunctionVisibility { fn from(val: FunctionVisibility) -> Self { match val { - FunctionVisibility::Private => RawFunctionVisibility::Private, - FunctionVisibility::ClientCallable => RawFunctionVisibility::ClientCallable, + FunctionVisibility::Private => Self::Private, + FunctionVisibility::ClientCallable => Self::ClientCallable, + FunctionVisibility::Internal => Self::Internal, } } } @@ -2345,22 +2439,33 @@ pub struct ReducerDef { pub err_return_type: AlgebraicType, } -impl From for RawReducerDefV9 { - fn from(val: ReducerDef) -> Self { - RawReducerDefV9 { +impl TryFrom for RawReducerDefV9 { + type Error = SchemaConversionError; + fn try_from(val: ReducerDef) -> Result { + if val.lifecycle.is_none() && !val.visibility.is_client_callable() { + return Err(SchemaConversionError { + target: RawModuleDefVersion::V9OrEarlier, + }); + } + Ok(RawReducerDefV9 { name: val.name.into(), params: val.params, lifecycle: val.lifecycle, - } + }) } } impl From for RawReducerDefV10 { fn from(val: ReducerDef) -> Self { + let visibility = if val.lifecycle.is_some() { + RawFunctionVisibility::Private + } else { + val.visibility.into() + }; RawReducerDefV10 { source_name: val.accessor_name.into(), params: val.params, - visibility: val.visibility.into(), + visibility, ok_return_type: val.ok_return_type, err_return_type: val.err_return_type, } @@ -2423,13 +2528,19 @@ pub struct HttpRouteDef { pub path: Box, } -impl From for RawProcedureDefV9 { - fn from(val: ProcedureDef) -> Self { - RawProcedureDefV9 { +impl TryFrom for RawProcedureDefV9 { + type Error = SchemaConversionError; + fn try_from(val: ProcedureDef) -> Result { + if !val.visibility.is_client_callable() { + return Err(SchemaConversionError { + target: RawModuleDefVersion::V9OrEarlier, + }); + } + Ok(RawProcedureDefV9 { name: val.name.into(), params: val.params, return_type: val.return_type, - } + }) } } @@ -2444,9 +2555,10 @@ impl From for RawProcedureDefV10 { } } -impl From for RawMiscModuleExportV9 { - fn from(def: ProcedureDef) -> Self { - Self::Procedure(def.into()) +impl TryFrom for RawMiscModuleExportV9 { + type Error = SchemaConversionError; + fn try_from(def: ProcedureDef) -> Result { + Ok(Self::Procedure(def.try_into()?)) } } diff --git a/crates/schema/src/def/validate/v10.rs b/crates/schema/src/def/validate/v10.rs index d6303f3a81c..416d8b415ed 100644 --- a/crates/schema/src/def/validate/v10.rs +++ b/crates/schema/src/def/validate/v10.rs @@ -78,6 +78,47 @@ impl From for ValidationCase { /// Validate a `RawModuleDefV10` and convert it into a `ModuleDef`, /// or return a stream of errors if the definition is invalid. pub fn validate(def: RawModuleDefV10) -> Result { + let mut seen_capabilities = false; + let mut capabilities = std::collections::BTreeSet::new(); + for section in &def.sections { + if let RawModuleDefV10Section::Capabilities(names) = section { + if seen_capabilities { + return Err(ValidationError::DuplicateModuleSection { + section: "Capabilities".into(), + } + .into()); + } + seen_capabilities = true; + if names.len() > 32 { + return Err(ValidationError::InvalidModuleCapabilities.into()); + } + for name in names { + if name.is_empty() + || name.len() > 64 + || !name + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_') + || !capabilities.insert(name.clone()) + { + return Err(ValidationError::InvalidModuleCapabilities.into()); + } + } + } + } + // Retain the raw distinction until schedules are attached. Tag 1 has the + // historical contextual default; tag 3 is an explicit public declaration. + let raw_visibility: HashMap<_, _> = def + .reducers() + .into_iter() + .flatten() + .map(|function| (function.source_name.clone(), function.visibility)) + .chain( + def.procedures() + .into_iter() + .flatten() + .map(|function| (function.source_name.clone(), function.visibility)), + ) + .collect(); let mut typespace = def.typespace().cloned().unwrap_or_else(|| Typespace::EMPTY.clone()); let known_type_definitions = def.types().into_iter().flatten().map(|def| def.ty); let case_policy = def.case_conversion_policy().into(); @@ -276,7 +317,12 @@ pub fn validate(def: RawModuleDefV10) -> Result { attach_schedules_to_tables(&mut tables, schedules)?; check_scheduled_functions_exist(&mut tables, &reducers, &procedures)?; - change_scheduled_functions_and_lifetimes_visibility(&tables, &mut reducers, &mut procedures)?; + change_scheduled_functions_and_lifetimes_visibility( + &tables, + &mut reducers, + &mut procedures, + &raw_visibility, + )?; attach_view_primary_keys(&mut views, view_primary_keys)?; assign_query_view_primary_keys(&tables, &mut views); @@ -320,6 +366,7 @@ pub fn validate(def: RawModuleDefV10) -> Result { procedures, http_handlers, http_routes, + capabilities, raw_module_def_version: RawModuleDefVersion::V10, submodules, }; @@ -383,12 +430,13 @@ fn validate_submodules(submodules: Vec) -> Result, reducers: &mut IndexMap, procedures: &mut IndexMap, + raw_visibility: &HashMap, ) -> Result<()> { for sched_def in tables.iter().filter_map(|(_, t)| t.schedule.as_ref()) { match sched_def.function_kind { @@ -400,7 +448,12 @@ fn change_scheduled_functions_and_lifetimes_visibility( } })?; - def.visibility = crate::def::FunctionVisibility::Private; + if matches!( + raw_visibility.get(&RawIdentifier::from(def.accessor_name.clone())), + Some(RawFunctionVisibility::ClientCallable) + ) { + def.visibility = crate::def::FunctionVisibility::Private; + } } FunctionKind::Procedure => { @@ -411,7 +464,12 @@ fn change_scheduled_functions_and_lifetimes_visibility( } })?; - def.visibility = crate::def::FunctionVisibility::Private; + if matches!( + raw_visibility.get(&RawIdentifier::from(def.accessor_name.clone())), + Some(RawFunctionVisibility::ClientCallable) + ) { + def.visibility = crate::def::FunctionVisibility::Private; + } } FunctionKind::Unknown => {} @@ -420,7 +478,16 @@ fn change_scheduled_functions_and_lifetimes_visibility( for red_def in reducers.iter_mut().map(|(_, r)| r) { if red_def.lifecycle.is_some() { - red_def.visibility = crate::def::FunctionVisibility::Private; + if matches!( + raw_visibility.get(&RawIdentifier::from(red_def.accessor_name.clone())), + Some(RawFunctionVisibility::ExplicitClientCallable) + ) { + return Err(ValidationError::InvalidLifecycleVisibility { + function: red_def.accessor_name.clone().into(), + } + .into()); + } + red_def.visibility = crate::def::FunctionVisibility::Internal; } } @@ -1460,7 +1527,7 @@ mod tests { def.reducers[&check_deliveries_name].visibility, FunctionVisibility::Private, ); - assert_eq!(def.reducers[&init_name].visibility, FunctionVisibility::Private); + assert_eq!(def.reducers[&init_name].visibility, FunctionVisibility::Internal); assert_eq!( def.reducers[&extra_reducer_name].visibility, FunctionVisibility::ClientCallable @@ -2796,3 +2863,277 @@ mod tests { }); } } + +#[cfg(test)] +mod visibility_tests { + use super::*; + use crate::def::FunctionVisibility; + use spacetimedb_lib::db::raw_def::v10; + use spacetimedb_lib::{db::raw_def::v9, RawModuleDef, ScheduleAt}; + use spacetimedb_sats::{AlgebraicType, ProductType}; + use v10::{FunctionVisibility as Declared, RawModuleDefV10Builder}; + + fn scheduled_module(visibility: Option, procedure: bool) -> ModuleDef { + let mut builder = RawModuleDefV10Builder::new(); + let at = builder.add_type::(); + let row = builder + .build_table_with_new_type( + "Jobs", + ProductType::from([("id", AlgebraicType::U64), ("at", at)]), + true, + ) + .with_auto_inc_primary_key(0) + .with_index_no_accessor_name(v9::btree(0), "jobs_id_idx") + .finish(); + let params = ProductType::from([("job", AlgebraicType::Ref(row))]); + if procedure { + builder.add_procedure_with_visibility("run_job", params, AlgebraicType::unit(), visibility); + } else { + builder.add_reducer_with_visibility("run_job", params, visibility); + } + builder.add_schedule("Jobs", 1, "run_job"); + builder.finish().try_into().unwrap() + } + + #[test] + fn explicit_scheduled_visibility_overrides_the_private_default() { + for procedure in [false, true] { + for (selection, expected) in [ + (None, FunctionVisibility::Private), + (Some(Declared::Private), FunctionVisibility::Private), + (Some(Declared::Internal), FunctionVisibility::Internal), + (Some(Declared::ClientCallable), FunctionVisibility::ClientCallable), + ] { + let module = scheduled_module(selection, procedure); + let visibility = if procedure { + &module.procedure("run_job").unwrap().visibility + } else { + &module.reducer("run_job").unwrap().visibility + }; + assert_eq!(visibility, &expected); + assert_eq!(module.raw_module_def_version(), RawModuleDefVersion::V10); + } + } + } + + #[test] + fn ordinary_defaults_and_lifecycle_restrictions() { + let mut builder = RawModuleDefV10Builder::new(); + builder.add_reducer("ordinary", ProductType::unit()); + builder.add_procedure("ordinary_procedure", ProductType::unit(), AlgebraicType::unit()); + builder.add_lifecycle_reducer(v9::Lifecycle::Init, "initialize", ProductType::unit()); + let module: ModuleDef = builder.finish().try_into().unwrap(); + assert!(module.reducer("ordinary").unwrap().visibility.is_client_callable()); + assert!(module + .procedure("ordinary_procedure") + .unwrap() + .visibility + .is_client_callable()); + assert!(module.reducer("initialize").unwrap().visibility.is_internal()); + let exported: RawModuleDefV10 = module.into(); + assert!(exported + .reducers() + .into_iter() + .flatten() + .all(|function| matches!(function.visibility, Declared::ClientCallable | Declared::Private))); + assert!(exported + .procedures() + .into_iter() + .flatten() + .all(|function| matches!(function.visibility, Declared::ClientCallable))); + for selection in [Declared::ClientCallable, Declared::ExplicitClientCallable] { + let mut builder = RawModuleDefV10Builder::new(); + builder.add_lifecycle_reducer_with_visibility( + v9::Lifecycle::Init, + "initialize", + ProductType::unit(), + Some(selection), + ); + assert!(ModuleDef::try_from(builder.finish()) + .unwrap_err() + .to_string() + .contains("must have Internal visibility")); + } + let mut builder = RawModuleDefV10Builder::new(); + builder.add_lifecycle_reducer_with_visibility( + v9::Lifecycle::Init, + "initialize", + ProductType::unit(), + Some(Declared::Internal), + ); + assert!(ModuleDef::try_from(builder.finish()).is_ok()); + } + + #[test] + fn duplicate_definitions_sections_and_lifecycles_are_rejected() { + let mut builder = RawModuleDefV10Builder::new(); + builder.add_reducer("same", ProductType::unit()); + builder.add_procedure("same", ProductType::unit(), AlgebraicType::unit()); + assert!(ModuleDef::try_from(builder.finish()).is_err()); + let raw = v10::RawModuleDefV10 { + sections: vec![ + v10::RawModuleDefV10Section::Capabilities(vec![]), + v10::RawModuleDefV10Section::Capabilities(vec![]), + ], + }; + assert!(ModuleDef::try_from(raw) + .unwrap_err() + .to_string() + .contains("repeated V10 section")); + let mut builder = RawModuleDefV10Builder::new(); + builder.add_lifecycle_reducer(v9::Lifecycle::Init, "a", ProductType::unit()); + builder.add_lifecycle_reducer(v9::Lifecycle::Init, "b", ProductType::unit()); + assert!(ModuleDef::try_from(builder.finish()).is_err()); + } + + #[test] + fn resolved_v10_roundtrips_without_reapplying_defaults_and_rejects_v9_exports() { + for procedure in [false, true] { + for selection in [ + None, + Some(Declared::Private), + Some(Declared::Internal), + Some(Declared::ClientCallable), + ] { + let module = scheduled_module(selection, procedure); + assert!(v9::RawModuleDefV9::try_from(module.clone()).is_err()); + let RawModuleDef::V10(raw) = module.clone().into_raw() else { + panic!("lost source version") + }; + if matches!(selection, Some(Declared::ClientCallable)) { + assert!(raw + .reducers() + .into_iter() + .flatten() + .map(|function| &function.visibility) + .chain( + raw.procedures() + .into_iter() + .flatten() + .map(|function| &function.visibility) + ) + .all(|visibility| matches!(visibility, Declared::ExplicitClientCallable))); + } + let bytes = spacetimedb_lib::bsatn::to_vec(&RawModuleDef::V10(raw)).unwrap(); + let roundtrip: RawModuleDef = spacetimedb_lib::bsatn::from_slice(&bytes).unwrap(); + let roundtrip: ModuleDef = roundtrip.try_into().unwrap(); + if procedure { + assert_eq!( + roundtrip.procedure("run_job").unwrap().visibility, + module.procedure("run_job").unwrap().visibility + ); + } else { + assert_eq!( + roundtrip.reducer("run_job").unwrap().visibility, + module.reducer("run_job").unwrap().visibility + ); + } + assert_eq!(roundtrip.raw_module_def_version(), RawModuleDefVersion::V10); + } + } + } + + #[test] + fn legacy_v9_schedules_stay_public_and_v10_schedules_stay_private() { + let mut builder = v9::RawModuleDefV9Builder::new(); + let at = builder.add_type::(); + let row = builder + .build_table_with_new_type( + "jobs", + ProductType::from([("id", AlgebraicType::U64), ("at", at)]), + true, + ) + .with_auto_inc_primary_key(0) + .with_index(v9::btree(0), "jobs_id_idx") + .with_schedule("run_job", 1) + .finish(); + builder.add_reducer("run_job", ProductType::from([("job", row.into())]), None); + let v9: ModuleDef = builder.finish().try_into().unwrap(); + assert!(v9.reducer("run_job").unwrap().visibility.is_client_callable()); + let upgraded: RawModuleDefV10 = v9.clone().into(); + assert!(matches!( + upgraded.reducers().unwrap()[0].visibility, + Declared::ExplicitClientCallable + )); + let upgraded: ModuleDef = upgraded.try_into().unwrap(); + assert!(upgraded.reducer("run_job").unwrap().visibility.is_client_callable()); + assert!(matches!(v9.into_raw(), RawModuleDef::V9(_))); + + let mut builder = v10::RawModuleDefV10Builder::new(); + let at = builder.add_type::(); + let row = builder + .build_table_with_new_type( + "jobs", + ProductType::from([("id", AlgebraicType::U64), ("at", at)]), + true, + ) + .with_auto_inc_primary_key(0) + .with_index_no_accessor_name(v9::btree(0), "jobs_id_idx") + .finish(); + builder.add_reducer("run_job", ProductType::from([("job", row.into())])); + builder.add_schedule("jobs", 1, "run_job"); + let v10: ModuleDef = builder.finish().try_into().unwrap(); + assert!(v10.reducer("run_job").unwrap().visibility.is_private()); + assert!(matches!(v10.into_raw(), RawModuleDef::V10(_))); + } + + #[test] + fn capabilities_are_explicit_bounded_and_preserved() { + let bare: ModuleDef = RawModuleDefV10Builder::new().finish().try_into().unwrap(); + assert!(!bare.supports_hosted_auth_v1()); + let mut builder = RawModuleDefV10Builder::new(); + builder.add_capability("hosted_auth_v1"); + let module: ModuleDef = builder.finish().try_into().unwrap(); + assert!(module.supports_hosted_auth_v1()); + let reloaded: ModuleDef = module.into_raw().try_into().unwrap(); + assert!(reloaded.supports_hosted_auth_v1()); + for names in [ + vec!["".to_string()], + vec!["Uppercase".to_string()], + vec!["with-dash".to_string()], + vec!["a".repeat(65)], + vec!["duplicate".to_string(); 2], + (0..33).map(|i| format!("cap_{i}")).collect(), + ] { + let mut builder = RawModuleDefV10Builder::new(); + for name in names { + builder.add_capability(name); + } + assert!(ModuleDef::try_from(builder.finish()).is_err()); + } + } + + #[test] + fn narrowing_function_visibility_is_a_reported_client_break() { + let module = |visibility| { + let mut builder = RawModuleDefV10Builder::new(); + builder.add_reducer_with_visibility("run_now", ProductType::unit(), Some(visibility)); + ModuleDef::try_from(builder.finish()).unwrap() + }; + let public = module(Declared::ClientCallable); + let internal = module(Declared::Internal); + let plan = crate::auto_migrate::ponder_migrate(&public, &internal).unwrap(); + assert!(plan.breaks_client()); + let display = plan + .pretty_print(crate::auto_migrate::PrettyPrintStyle::NoColor) + .unwrap(); + assert!(display.contains("run_now")); + assert!(display.contains("Internal")); + assert!(!crate::auto_migrate::ponder_migrate(&internal, &public) + .unwrap() + .breaks_client()); + } + + #[test] + fn visibility_authority_is_cumulative_without_elevating_the_owner() { + for (visibility, external, owner, internal) in [ + (FunctionVisibility::Internal, false, false, true), + (FunctionVisibility::Private, false, true, true), + (FunctionVisibility::ClientCallable, true, true, true), + ] { + assert_eq!(visibility.allows_invocation(false, false), external); + assert_eq!(visibility.allows_invocation(false, true), owner); + assert_eq!(visibility.allows_invocation(true, false), internal); + } + } +} diff --git a/crates/schema/src/def/validate/v9.rs b/crates/schema/src/def/validate/v9.rs index 8de4927afa2..c1c91fa4d4f 100644 --- a/crates/schema/src/def/validate/v9.rs +++ b/crates/schema/src/def/validate/v9.rs @@ -169,6 +169,7 @@ pub fn validate(def: RawModuleDefV9) -> Result { procedures, http_handlers: IndexMap::new(), http_routes: Vec::new(), + capabilities: Default::default(), raw_module_def_version: RawModuleDefVersion::V9OrEarlier, submodules: IndexMap::new(), }; @@ -389,7 +390,11 @@ impl ModuleValidatorV9<'_> { recursive: false, // A ProductTypeDef not stored in a Typespace cannot be recursive. }, lifecycle, - visibility: FunctionVisibility::ClientCallable, + visibility: if lifecycle.is_some() { + FunctionVisibility::Internal + } else { + FunctionVisibility::ClientCallable + }, ok_return_type: reducer_default_ok_return_type(), err_return_type: reducer_default_err_return_type(), }; diff --git a/crates/schema/src/error.rs b/crates/schema/src/error.rs index e9408a35482..434f44ba727 100644 --- a/crates/schema/src/error.rs +++ b/crates/schema/src/error.rs @@ -22,6 +22,14 @@ pub type ValidationErrors = ErrorStream; #[derive(thiserror::Error, Debug, PartialOrd, Ord, PartialEq, Eq)] #[non_exhaustive] pub enum ValidationError { + #[error("unsupported module definition version")] + UnsupportedModuleVersion, + #[error("invalid module capabilities: at most 32 unique names of 1..64 lowercase ASCII letters, digits or underscores are allowed")] + InvalidModuleCapabilities, + #[error("lifecycle reducer `{function}` must have Internal visibility")] + InvalidLifecycleVisibility { function: RawIdentifier }, + #[error("module contains repeated V10 section `{section}`")] + DuplicateModuleSection { section: String }, #[error("name `{name}` is used for multiple entities")] DuplicateName { name: RawIdentifier }, #[error("name `{name}` is used for multiple types")] diff --git a/crates/standalone/src/subcommands/extract_schema.rs b/crates/standalone/src/subcommands/extract_schema.rs index efc77960195..874f213c183 100644 --- a/crates/standalone/src/subcommands/extract_schema.rs +++ b/crates/standalone/src/subcommands/extract_schema.rs @@ -4,7 +4,7 @@ use anyhow::Context; use clap::{ArgMatches, CommandFactory, FromArgMatches}; use spacetimedb::host::extract_schema; use spacetimedb::messages::control_db; -use spacetimedb_lib::{db::raw_def::v10::RawModuleDefV10, sats, RawModuleDef}; +use spacetimedb_lib::sats; /// Extracts the module schema from a local module file. /// WARNING: This command is UNSTABLE and subject to breaking changes. @@ -67,7 +67,7 @@ pub async fn exec(args: &ArgMatches) -> anyhow::Result<()> { let module_def = extract_schema(program_bytes.into(), host_type.into()).await?; - let raw_def = RawModuleDef::V10(RawModuleDefV10::from(module_def)); + let raw_def = module_def.into_raw(); serde_json::to_writer(std::io::stdout().lock(), &sats::serde::SerdeWrapper(raw_def))?; diff --git a/crates/testing/tests/invocation_flags.rs b/crates/testing/tests/invocation_flags.rs new file mode 100644 index 00000000000..16581de6662 --- /dev/null +++ b/crates/testing/tests/invocation_flags.rs @@ -0,0 +1,76 @@ +//! Exercise real Rust Wasm bindings and host admission without a server endpoint. +use serial_test::serial; +use spacetimedb::host::FunctionArgs; +use spacetimedb_lib::{AlgebraicValue, Identity}; +use spacetimedb_testing::modules::{CompilationMode, CompiledModule, DEFAULT_CONFIG}; +use std::time::Duration; + +#[test] +#[serial] +fn wasm_invocation_flags_do_not_infer_authority_from_identity_or_connection_absence() { + CompiledModule::compile("invocation-flags-test", CompilationMode::Debug).with_module_async( + DEFAULT_CONFIG, + |handle| async move { + let module = handle.client.module(); + for sender in [Identity::ZERO, Identity::ONE, handle.db_identity] { + module + .call_reducer(sender, None, None, None, None, "external", FunctionArgs::Nullary) + .await + .unwrap() + .outcome + .into_result() + .unwrap(); + for name in ["internal", "init", "scheduled"] { + assert!(module + .call_reducer(sender, None, None, None, None, name, FunctionArgs::Nullary) + .await + .is_err()); + } + let result = module + .call_procedure(sender, None, None, "external_procedure", FunctionArgs::Nullary) + .await; + assert_eq!(result.result.unwrap().return_val, AlgebraicValue::Bool(true)); + assert!(module + .call_procedure(sender, None, None, "internal_procedure", FunctionArgs::Nullary) + .await + .result + .is_err()); + assert_eq!( + module + .call_reducer(sender, None, None, None, None, "private", FunctionArgs::Nullary) + .await + .is_ok(), + sender == Identity::ZERO, + ); + } + module + .call_reducer( + Identity::ZERO, + None, + None, + None, + None, + "schedule", + FunctionArgs::Nullary, + ) + .await + .unwrap() + .outcome + .into_result() + .unwrap(); + tokio::time::timeout(Duration::from_secs(5), async { + loop { + let result = module + .call_procedure(Identity::ZERO, None, None, "scheduled_finished", FunctionArgs::Nullary) + .await; + if result.result.unwrap().return_val == AlgebraicValue::Bool(true) { + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .expect("the real scheduled reducer did not observe trusted internal authority"); + }, + ); +} diff --git a/modules/invocation-flags-test/Cargo.toml b/modules/invocation-flags-test/Cargo.toml new file mode 100644 index 00000000000..8b34d4125a9 --- /dev/null +++ b/modules/invocation-flags-test/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "invocation-flags-test" +version = "0.0.0" +edition.workspace = true +license-file = "../../LICENSE.txt" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies.spacetimedb] +workspace = true +features = ["unstable"] diff --git a/modules/invocation-flags-test/src/lib.rs b/modules/invocation-flags-test/src/lib.rs new file mode 100644 index 00000000000..9621ef95f59 --- /dev/null +++ b/modules/invocation-flags-test/src/lib.rs @@ -0,0 +1,78 @@ +//! Real Wasm fixture for generic host invocation authority. +use spacetimedb::{ProcedureContext, ReducerContext, Table}; + +#[spacetimedb::reducer(init)] +pub fn init(ctx: &ReducerContext) { + assert!(ctx.sender_auth().is_internal()); + assert!(!ctx.sender_auth().has_jwt()); +} + +#[spacetimedb::reducer] +pub fn external(ctx: &ReducerContext) { + assert!(!ctx.sender_auth().is_internal()); + assert_eq!(ctx.connection_id(), None); + assert!(!ctx.sender_auth().has_jwt()); +} + +#[spacetimedb::reducer(internal)] +pub fn internal(ctx: &ReducerContext) { + assert!(ctx.sender_auth().is_internal()); +} + +#[spacetimedb::reducer(private)] +pub fn private(_ctx: &ReducerContext) {} + +#[spacetimedb::procedure] +pub fn external_procedure(ctx: &mut ProcedureContext) -> bool { + assert!(!ctx.sender_auth().is_internal()); + let sender = ctx.sender(); + let connection = ctx.connection_id(); + ctx.with_tx(|tx| { + assert!(!tx.sender_auth().is_internal()); + assert_eq!(tx.sender(), sender); + assert_eq!(tx.connection_id(), connection); + }); + true +} + +#[spacetimedb::procedure(internal)] +pub fn internal_procedure(ctx: &mut ProcedureContext) -> bool { + assert!(ctx.sender_auth().is_internal()); + true +} + +#[spacetimedb::table(accessor = jobs, scheduled(scheduled))] +pub struct Job { + #[primary_key] + #[auto_inc] + id: u64, + scheduled_at: spacetimedb::ScheduleAt, +} + +#[spacetimedb::table(accessor = finished)] +pub struct Finished { + #[primary_key] + id: u64, +} + +#[spacetimedb::reducer] +pub fn schedule(ctx: &ReducerContext) { + ctx.db.jobs().insert(Job { + id: 0, + scheduled_at: ctx.timestamp.into(), + }); +} + +#[spacetimedb::reducer(internal)] +pub fn scheduled(ctx: &ReducerContext, job: Job) { + assert!(ctx.sender_auth().is_internal()); + assert_eq!(ctx.sender(), ctx.database_identity()); + assert_eq!(ctx.connection_id(), None); + assert!(!ctx.sender_auth().has_jwt()); + ctx.db.finished().insert(Finished { id: job.id }); +} + +#[spacetimedb::procedure] +pub fn scheduled_finished(ctx: &mut ProcedureContext) -> bool { + ctx.with_tx(|tx| tx.db.finished().iter().next().is_some()) +} diff --git a/modules/module-test-ts/src/index.ts b/modules/module-test-ts/src/index.ts index 10520d2b72c..46c3116e9e7 100644 --- a/modules/module-test-ts/src/index.ts +++ b/modules/module-test-ts/src/index.ts @@ -516,7 +516,7 @@ export const getMySchemaViaHttp = spacetimedb.procedure(t.string(), ctx => { const module_identity = ctx.databaseIdentity; try { const response = ctx.http.fetch( - `http://localhost:3000/v1/database/${module_identity}/schema?version=9` + `http://localhost:3000/v1/database/${module_identity}/schema?version=10` ); return response.text(); } catch (e) { diff --git a/modules/module-test/src/lib.rs b/modules/module-test/src/lib.rs index fc1851b21b0..dfdc27e8e9d 100644 --- a/modules/module-test/src/lib.rs +++ b/modules/module-test/src/lib.rs @@ -546,7 +546,7 @@ fn with_tx(ctx: &mut ProcedureContext) { fn get_my_schema_via_http(ctx: &mut ProcedureContext) -> String { let module_identity = ctx.database_identity(); match ctx.http.get(format!( - "http://localhost:3000/v1/database/{module_identity}/schema?version=9" + "http://localhost:3000/v1/database/{module_identity}/schema?version=10" )) { Ok(result) => result.into_body().into_string_lossy(), Err(e) => format!("{e}"), diff --git a/modules/sdk-test-procedure-ts/src/index.ts b/modules/sdk-test-procedure-ts/src/index.ts index 1885eafd156..76efe296c17 100644 --- a/modules/sdk-test-procedure-ts/src/index.ts +++ b/modules/sdk-test-procedure-ts/src/index.ts @@ -97,7 +97,7 @@ export const read_my_schema = spacetimedb.procedure( const module_identity = ctx.databaseIdentity; const base_url = server_url.replace(/\/+$/, ''); const response = ctx.http.fetch( - `${base_url}/v1/database/${module_identity}/schema?version=9` + `${base_url}/v1/database/${module_identity}/schema?version=10` ); return response.text(); } diff --git a/modules/sdk-test-procedure/src/lib.rs b/modules/sdk-test-procedure/src/lib.rs index 5eb2f848ad5..c9af396f4f2 100644 --- a/modules/sdk-test-procedure/src/lib.rs +++ b/modules/sdk-test-procedure/src/lib.rs @@ -46,7 +46,7 @@ fn read_my_schema(ctx: &mut ProcedureContext, server_url: String) -> String { let server_url = server_url.trim_end_matches('/'); match ctx .http - .get(format!("{server_url}/v1/database/{module_identity}/schema?version=9")) + .get(format!("{server_url}/v1/database/{module_identity}/schema?version=10")) { Ok(result) => result.into_body().into_string_lossy(), Err(e) => panic!("{e}"), diff --git a/sdks/rust/tests/procedure-client/src/test_handlers.rs b/sdks/rust/tests/procedure-client/src/test_handlers.rs index fdfc417cd9b..e81cf5b3724 100644 --- a/sdks/rust/tests/procedure-client/src/test_handlers.rs +++ b/sdks/rust/tests/procedure-client/src/test_handlers.rs @@ -1,7 +1,7 @@ use crate::module_bindings::*; use anyhow::Context; use core::time::Duration; -use spacetimedb_lib::db::raw_def::v9::{RawMiscModuleExportV9, RawModuleDefV9}; +use spacetimedb_lib::db::raw_def::v10::{RawModuleDefV10, RawModuleDefV10Section}; use spacetimedb_sdk::{DbConnectionBuilder, DbContext, Table}; use test_counter::{server_url, TestCounter}; @@ -247,7 +247,7 @@ async fn exec_insert_with_tx_rollback(db_name: &str) { /// Test that a procedure can perform an HTTP request and return a string derived from the response. /// /// Invoke the procedure `read_my_schema`, -/// which does an HTTP request to the `/database/schema` route and returns a JSON-ified [`RawModuleDefV9`], +/// which does an HTTP request to the `/database/schema` route and returns a JSON-ified [`RawModuleDefV10`], /// then (in the client) deserialize the response and assert that it contains a description of that procedure. async fn exec_procedure_http_ok(db_name: &str) { let test_counter = TestCounter::new(); @@ -262,12 +262,14 @@ async fn exec_procedure_http_ok(db_name: &str) { #[allow(clippy::redundant_closure_call)] (|| { anyhow::ensure!(res.is_ok(), "Expected Ok result but got {res:?}"); - let module_def: RawModuleDefV9 = spacetimedb_lib::de::serde::deserialize_from( + let module_def: RawModuleDefV10 = spacetimedb_lib::de::serde::deserialize_from( &mut serde_json::Deserializer::from_str(&res.unwrap()), )?; - anyhow::ensure!(module_def.misc_exports.iter().any(|misc_export| { - if let RawMiscModuleExportV9::Procedure(procedure_def) = misc_export { - &*procedure_def.name == "read_my_schema" + anyhow::ensure!(module_def.sections.iter().any(|section| { + if let RawModuleDefV10Section::Procedures(procedures) = section { + procedures + .iter() + .any(|procedure| &*procedure.source_name == "read_my_schema") } else { false } From 8ef580d2033a904ad2185672402f3a51534a04e3 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Tue, 8 Sep 2026 15:17:54 -0400 Subject: [PATCH 2/9] Preserve qualified reducer names and complete stacked binding tests --- .../tests/__mocks__/spacetime-environment.ts | 2 ++ crates/bindings-typescript/vitest.config.ts | 10 +++++++++- crates/schema/src/auto_migrate.rs | 4 ++-- 3 files changed, 13 insertions(+), 3 deletions(-) create mode 100644 crates/bindings-typescript/tests/__mocks__/spacetime-environment.ts diff --git a/crates/bindings-typescript/tests/__mocks__/spacetime-environment.ts b/crates/bindings-typescript/tests/__mocks__/spacetime-environment.ts new file mode 100644 index 00000000000..5202167f3f7 --- /dev/null +++ b/crates/bindings-typescript/tests/__mocks__/spacetime-environment.ts @@ -0,0 +1,2 @@ +// Tests have no environment unless a fixture supplies one. +export const env_get = (_name: string): string | null => null; diff --git a/crates/bindings-typescript/vitest.config.ts b/crates/bindings-typescript/vitest.config.ts index 9f80a60db65..3676b551d58 100644 --- a/crates/bindings-typescript/vitest.config.ts +++ b/crates/bindings-typescript/vitest.config.ts @@ -16,7 +16,15 @@ export default defineConfig({ { find: 'spacetime:sys@2.1', replacement: sysMock }, { find: 'spacetime:sys@2.2', - replacement: fileURLToPath(new URL('./tests/__mocks__/spacetime-auth.ts', import.meta.url)), + replacement: fileURLToPath( + new URL('./tests/__mocks__/spacetime-auth.ts', import.meta.url) + ), + }, + { + find: 'spacetime:sys@2.3', + replacement: fileURLToPath( + new URL('./tests/__mocks__/spacetime-environment.ts', import.meta.url) + ), }, ], }, diff --git a/crates/schema/src/auto_migrate.rs b/crates/schema/src/auto_migrate.rs index db728fcd250..4a52e079670 100644 --- a/crates/schema/src/auto_migrate.rs +++ b/crates/schema/src/auto_migrate.rs @@ -229,8 +229,8 @@ impl AutoMigratePlan<'_> { .all_reducers_with_prefix() .into_iter() .filter(|(_, _, old)| old.lifecycle.is_none()) - .filter_map(|(prefix, _, old)| { - let name = format!("{prefix}{}", old.name); + .filter_map(|(_, _, old)| { + let name = old.name.to_string(); let (_, new) = self.new.reducer_by_name(&name)?; (old.visibility != new.visibility).then_some((name, &old.visibility, &new.visibility)) }); From 203a022da3052b0f7cf5f286d94d69b4b130154a Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Tue, 8 Sep 2026 15:20:42 -0400 Subject: [PATCH 3/9] Adapt invocation authority host fixture to current engine construction --- crates/core/src/host/host_controller/invocation_flags_tests.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/core/src/host/host_controller/invocation_flags_tests.rs b/crates/core/src/host/host_controller/invocation_flags_tests.rs index a451cf00dc7..b438ef187e7 100644 --- a/crates/core/src/host/host_controller/invocation_flags_tests.rs +++ b/crates/core/src/host/host_controller/invocation_flags_tests.rs @@ -65,6 +65,7 @@ async fn invocation_flags_are_host_owned_and_internal_visibility_is_enforced() { HostRuntimeConfig::default(), Arc::new(storage), Arc::new(NullEnergyMonitor), + Arc::new(()), Arc::new(LocalPersistenceProvider::new(data)), JobCores::without_pinned_cores(), ); @@ -74,6 +75,7 @@ async fn invocation_flags_are_host_owned_and_internal_visibility_is_enforced() { owner_identity: Identity::ONE, host_type: HostType::Js, initial_program: program.hash, + bootstrap_generation: 0, }; // The init reducer itself asserts flags=1, so successful construction also // verifies the real host-to-JS syscall path for a trusted lifecycle call. From 55ef08da2c406ca5002b8ffe55074e7861df1aa6 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Tue, 8 Sep 2026 15:26:53 -0400 Subject: [PATCH 4/9] Retain current compiler feature flags in visibility codegen regression --- Cargo.lock | 2 +- crates/bindings-csharp/Codegen.Tests/Tests.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 65172309213..0020e4b5c2b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2302,7 +2302,7 @@ dependencies = [ name = "environment-test" version = "0.0.0" dependencies = [ - "spacetimedb 2.10.0", + "spacetimedb", ] [[package]] diff --git a/crates/bindings-csharp/Codegen.Tests/Tests.cs b/crates/bindings-csharp/Codegen.Tests/Tests.cs index 6f0a33fa0e3..3f569ba00b4 100644 --- a/crates/bindings-csharp/Codegen.Tests/Tests.cs +++ b/crates/bindings-csharp/Codegen.Tests/Tests.cs @@ -365,7 +365,7 @@ public static void InternalJob(ReducerContext ctx) {} public static int InternalProcedure(ProcedureContext ctx) => 1; } """; - var parseOptions = new CSharpParseOptions(fixture.SampleCompilation.LanguageVersion); + var parseOptions = fixture.ParseOptions; var tree = CSharpSyntaxTree.ParseText(source, parseOptions); var compilation = fixture.SampleCompilation.AddSyntaxTrees(tree); var driver = CSharpGeneratorDriver.Create( From 29b778057bc5ad0bca61decbcd4899cc20d61d5c Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Wed, 9 Sep 2026 07:55:21 -0400 Subject: [PATCH 5/9] Format C# ENV and invocation authority imports --- crates/bindings-csharp/Runtime/Internal/FFI.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/bindings-csharp/Runtime/Internal/FFI.cs b/crates/bindings-csharp/Runtime/Internal/FFI.cs index 9dbef3ea2df..c0e30ffe7f7 100644 --- a/crates/bindings-csharp/Runtime/Internal/FFI.cs +++ b/crates/bindings-csharp/Runtime/Internal/FFI.cs @@ -132,11 +132,11 @@ public static unsafe partial CheckedStatus env_get( uint keyLen, out BytesSource source ); + [WasmImportLinkage] [LibraryImport(StdbNamespace10_7)] public static partial uint get_call_auth_flags(); - [NativeMarshalling(typeof(Marshaller))] public struct CheckedStatus { From 9847404241721d24a6fd2bdb98ff03141851d302 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Wed, 9 Sep 2026 08:07:23 -0400 Subject: [PATCH 6/9] Regenerate CLI docs for private function visibility --- .../00200-reference/00100-cli-reference/00100-cli-reference.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md b/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md index c820d565bb6..1908eef5f6c 100644 --- a/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md +++ b/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md @@ -445,7 +445,7 @@ Run `spacetime help generate` for more detailed information. Default value: `` * `--dotnet-version ` — Target .NET SDK major version for C# projects (e.g. 8 or 10). Auto-detected when omitted. -* `--include-private` — Include private tables and functions in generated code (types are always included). +* `--include-private` — Include private tables and private/internal non-lifecycle functions (types are always included). Default value: `false` * `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). From d8379a641701111a0e5c56592e0fcfa7a04f5bdb Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Wed, 9 Sep 2026 09:44:01 -0400 Subject: [PATCH 7/9] Align ABI regression fixtures with V10 schema metadata --- crates/bindings/tests/ui/tables.stderr | 16 ++++++++-------- .../examples~/regression-tests/server/Lib.cs | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/crates/bindings/tests/ui/tables.stderr b/crates/bindings/tests/ui/tables.stderr index 18b61f49224..7609d9ba378 100644 --- a/crates/bindings/tests/ui/tables.stderr +++ b/crates/bindings/tests/ui/tables.stderr @@ -209,13 +209,13 @@ error[E0277]: `&'a Alpha` cannot appear as an argument to an index filtering ope = note: The allowed set of types are limited to integers, bool, strings, `Identity`, `Uuid`, `Timestamp`, `ConnectionId`, `Hash` and no-payload enums which derive `SpacetimeType`, = help: the following other types implement trait `FilterableValue`: &ConnectionId - &ContainerMode &FunctionVisibility &Identity &Lifecycle - &PortExposure - &PortProtocol - &RestartPolicy + &TableAccess + &TableType + &bool + ðnum::int::I256 and $N others note: required by a bound in `UniqueColumn::::ColType, Col>::find` --> src/table.rs @@ -241,13 +241,13 @@ help: the trait `FilterableValue` is not implemented for `Alpha` | ^^^^^^^^^^^^ = help: the following other types implement trait `FilterableValue`: &ConnectionId - &ContainerMode &FunctionVisibility &Identity &Lifecycle - &PortExposure - &PortProtocol - &RestartPolicy + &TableAccess + &TableType + &bool + ðnum::int::I256 and $N others = note: required for `Alpha` to implement `IndexScanRangeBounds<(Alpha,), SingleBound>` note: required by a bound in `RangedIndex::::filter` diff --git a/sdks/csharp/examples~/regression-tests/server/Lib.cs b/sdks/csharp/examples~/regression-tests/server/Lib.cs index e3391b710ff..64e4f164da4 100644 --- a/sdks/csharp/examples~/regression-tests/server/Lib.cs +++ b/sdks/csharp/examples~/regression-tests/server/Lib.cs @@ -831,7 +831,7 @@ public static string ReadMySchemaViaHttp(ProcedureContext ctx) try { var moduleIdentity = ProcedureContext.Identity; - var uri = $"http://localhost:3000/v1/database/{moduleIdentity}/schema?version=9"; + var uri = $"http://localhost:3000/v1/database/{moduleIdentity}/schema?version=10"; var res = ctx.Http.Get(uri, System.TimeSpan.FromSeconds(2)); return res switch { From a420f86058c8ecdc538c43d6afa25e2b2fbf4ee4 Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Wed, 9 Sep 2026 11:24:25 -0400 Subject: [PATCH 8/9] Fix C++ and C# procedure tests to request V10 schemas --- modules/sdk-test-procedure-cpp/src/lib.cpp | 2 +- modules/sdk-test-procedure-cs/Lib.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/sdk-test-procedure-cpp/src/lib.cpp b/modules/sdk-test-procedure-cpp/src/lib.cpp index da1278ccdca..b5310efac63 100644 --- a/modules/sdk-test-procedure-cpp/src/lib.cpp +++ b/modules/sdk-test-procedure-cpp/src/lib.cpp @@ -151,7 +151,7 @@ SPACETIMEDB_PROCEDURE(std::string, read_my_schema, ProcedureContext ctx, std::st LOG_INFO("read_my_schema using identity: " + identity_hex); // Make HTTP GET request to the schema endpoint (matches Rust) - std::string url = server_url + "/v1/database/" + identity_hex + "/schema?version=9"; + std::string url = server_url + "/v1/database/" + identity_hex + "/schema?version=10"; auto result = ctx.http.get(url); if (!result.is_ok()) { diff --git a/modules/sdk-test-procedure-cs/Lib.cs b/modules/sdk-test-procedure-cs/Lib.cs index 2e405c3a9bc..30c4a8ed4ac 100644 --- a/modules/sdk-test-procedure-cs/Lib.cs +++ b/modules/sdk-test-procedure-cs/Lib.cs @@ -70,7 +70,7 @@ public static string ReadMySchema(ProcedureContext ctx, string serverUrl) { var moduleIdentity = ProcedureContextBase.Identity; serverUrl = serverUrl.TrimEnd('/'); - var result = ctx.Http.Get($"{serverUrl}/v1/database/{moduleIdentity}/schema?version=9"); + var result = ctx.Http.Get($"{serverUrl}/v1/database/{moduleIdentity}/schema?version=10"); return result.Match( response => response.Body.ToStringUtf8Lossy(), error => throw new Exception($"HTTP request failed: {error}") From 776689bdd487348fadbdf8d739d04fe35559121d Mon Sep 17 00:00:00 2001 From: Tyler Cloutier Date: Thu, 10 Sep 2026 06:24:10 -0400 Subject: [PATCH 9/9] Check canonical procedure names in V10 schema HTTP test --- .../procedure-client/src/test_handlers.rs | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/sdks/rust/tests/procedure-client/src/test_handlers.rs b/sdks/rust/tests/procedure-client/src/test_handlers.rs index e81cf5b3724..54e4d875ab0 100644 --- a/sdks/rust/tests/procedure-client/src/test_handlers.rs +++ b/sdks/rust/tests/procedure-client/src/test_handlers.rs @@ -1,7 +1,7 @@ use crate::module_bindings::*; use anyhow::Context; use core::time::Duration; -use spacetimedb_lib::db::raw_def::v10::{RawModuleDefV10, RawModuleDefV10Section}; +use spacetimedb_lib::db::raw_def::v10::{ExplicitNameEntry, RawModuleDefV10}; use spacetimedb_sdk::{DbConnectionBuilder, DbContext, Table}; use test_counter::{server_url, TestCounter}; @@ -265,14 +265,19 @@ async fn exec_procedure_http_ok(db_name: &str) { let module_def: RawModuleDefV10 = spacetimedb_lib::de::serde::deserialize_from( &mut serde_json::Deserializer::from_str(&res.unwrap()), )?; - anyhow::ensure!(module_def.sections.iter().any(|section| { - if let RawModuleDefV10Section::Procedures(procedures) = section { - procedures - .iter() - .any(|procedure| &*procedure.source_name == "read_my_schema") - } else { - false - } + // The schema endpoint exports source-to-canonical name mappings. + // C# uses `ReadMySchema` in source and `read_my_schema` on the wire. + let names = module_def.explicit_names().cloned().unwrap_or_default().into_entries(); + anyhow::ensure!(names.iter().any(|entry| { + let ExplicitNameEntry::Function(mapping) = entry else { + return false; + }; + &*mapping.canonical_name == "read_my_schema" + && module_def + .procedures() + .into_iter() + .flatten() + .any(|procedure| procedure.source_name == mapping.source_name) })); Ok(()) })(),