diff --git a/crates/bindings-cpp/README.md b/crates/bindings-cpp/README.md index 3aba197cb05..9ccfcc32a97 100644 --- a/crates/bindings-cpp/README.md +++ b/crates/bindings-cpp/README.md @@ -2,9 +2,29 @@ The SpacetimeDB C++ Module Library provides a modern C++20 API for building SpacetimeDB modules that run inside the database as WebAssembly. -## Invocation authentication +## Function visibility and invocation authentication -`ctx.sender_auth().is_internal()` captures host-verified invocation authority. JWT identity is the verified sender supplied by the host. Procedures preserve authentication in `with_tx` and `try_with_tx`. Internal authority is independent of connection and JWT presence. Newly compiled modules advertise `hosted_auth_v1` and require a compatible host. Function visibility and scheduled defaults are unchanged. +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 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/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/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/src/internal/v10_builder.cpp b/crates/bindings-cpp/src/internal/v10_builder.cpp index e03917e8561..f5726307b65 100644 --- a/crates/bindings-cpp/src/internal/v10_builder.cpp +++ b/crates/bindings-cpp/src/internal/v10_builder.cpp @@ -220,6 +220,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; @@ -228,24 +253,6 @@ 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); diff --git a/crates/bindings-cpp/tests/unit/CMakeLists.txt b/crates/bindings-cpp/tests/unit/CMakeLists.txt index f43799931b7..a26014fdd56 100644 --- a/crates/bindings-cpp/tests/unit/CMakeLists.txt +++ b/crates/bindings-cpp/tests/unit/CMakeLists.txt @@ -9,10 +9,21 @@ if(NOT CMAKE_SYSTEM_NAME STREQUAL "Emscripten") endif() add_executable(bindings_cpp_unit_tests - hosted_auth_unit_tests.cpp 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/function_visibility_unit_tests.cpp b/crates/bindings-cpp/tests/unit/function_visibility_unit_tests.cpp new file mode 100644 index 00000000000..95597eb5ff6 --- /dev/null +++ b/crates/bindings-cpp/tests/unit/function_visibility_unit_tests.cpp @@ -0,0 +1,115 @@ +#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_environment = 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_TRUE(section.get<15>().empty()); + saw_environment = true; + } else if (section.get_tag() == 16) { + ASSERT_EQ(std::vector{"hosted_auth_v1"}, section.get<16>()); + saw_capability = true; + } + } + ASSERT_TRUE(saw_reducers && saw_procedure && saw_environment && 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); + } +} + +TEST_CASE(v10_environment_and_capabilities_have_distinct_appended_wire_tags) { + RawModuleDefV10Section environment; + environment.set<15>(std::vector{}); + RawModuleDefV10Section capabilities; + capabilities.set<16>(std::vector{}); + for (const auto& section : {environment, capabilities}) { + std::vector bytes; + bsatn::Writer writer(bytes); + bsatn::serialize(writer, section); + const std::vector expected{section.get_tag(), 0, 0, 0, 0}; + ASSERT_EQ(expected, bytes); + } +} diff --git a/crates/bindings-csharp/Codegen.Tests/Tests.cs b/crates/bindings-csharp/Codegen.Tests/Tests.cs index 5ecccedb31b..39957bbef0c 100644 --- a/crates/bindings-csharp/Codegen.Tests/Tests.cs +++ b/crates/bindings-csharp/Codegen.Tests/Tests.cs @@ -352,6 +352,66 @@ 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 = fixture.ParseOptions; + 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(), + new EnvironmentGenerator().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 e81f122020d..8a5cb58534d 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 @@ -3304,7 +3304,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) ); @@ -3325,7 +3325,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 95bbf9c0516..82db7c6742d 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 @@ -2348,7 +2348,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 29fc16d2cfc..e90b494df50 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 06c7446836c..2aa4f8e4e9a 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. @@ -21,10 +42,6 @@ The [`Codegen`](./Codegen/) and [`Runtime`](./Runtime/) libraries are used: They provide all of the functionality needed to write SpacetimeDB modules in C#. See their READMEs for more information. -### Invocation authentication - -`ctx.SenderAuth.IsInternal` captures host-verified invocation authority. JWT identity is the verified sender supplied by the host. Internal authority is independent of connection and JWT presence. Newly compiled modules advertise `hosted_auth_v1` and require a compatible host. Function visibility and scheduled defaults are unchanged. - ### Declared environment A module may declare one `[SpacetimeDB.Env]` struct. `string` is required and diff --git a/crates/bindings-csharp/Runtime.Tests/FunctionVisibilityTests.cs b/crates/bindings-csharp/Runtime.Tests/FunctionVisibilityTests.cs new file mode 100644 index 00000000000..c92a47ad4ec --- /dev/null +++ b/crates/bindings-csharp/Runtime.Tests/FunctionVisibilityTests.cs @@ -0,0 +1,91 @@ +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); + Assert.Empty( + Assert.Single(raw.Sections.OfType()).Environment_ + ); + var capabilities = Assert.Single( + raw.Sections.OfType() + ); + Assert.Contains("hosted_auth_v1", capabilities.Capabilities_); + } + + [Fact] + public void EnvironmentAndCapabilitiesUseDistinctAppendedV10WireTags() + { + var serializer = new RawModuleDefV10Section.BSATN(); + Assert.Equal( + new byte[] { 15, 0, 0, 0, 0 }, + IStructuralReadWrite.ToBytes( + serializer, + new RawModuleDefV10Section.Environment([]) + ) + ); + Assert.Equal( + new byte[] { 16, 0, 0, 0, 0 }, + IStructuralReadWrite.ToBytes( + serializer, + new RawModuleDefV10Section.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/Attrs.cs b/crates/bindings-csharp/Runtime/Attrs.cs index afcfcc0688e..b06340d8aa1 100644 --- a/crates/bindings-csharp/Runtime/Attrs.cs +++ b/crates/bindings-csharp/Runtime/Attrs.cs @@ -210,18 +210,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/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/Module.cs b/crates/bindings-csharp/Runtime/Internal/Module.cs index 260a0ec3265..cb2cfc7c426 100644 --- a/crates/bindings-csharp/Runtime/Internal/Module.cs +++ b/crates/bindings-csharp/Runtime/Internal/Module.cs @@ -57,13 +57,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), 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-typescript/README.md b/crates/bindings-typescript/README.md index 6ac1d49dcf5..ddf546c296e 100644 --- a/crates/bindings-typescript/README.md +++ b/crates/bindings-typescript/README.md @@ -18,9 +18,25 @@ You can use the package in the browser, using a bundler like vite/parcel/rsbuild ### Usage -#### Module invocation authentication - -`ctx.senderAuth.isInternal` captures host-verified invocation authority independently of connection and JWT presence. `ctx.senderAuth.jwt.identity` is the verified sender. Procedure transactions preserve authentication. Newly compiled modules advertise `hosted_auth_v1` and require a compatible host. Function visibility and scheduled defaults are unchanged. +#### 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. diff --git a/crates/bindings-typescript/src/lib/autogen/types.ts b/crates/bindings-typescript/src/lib/autogen/types.ts index 6e47663aa70..40ab70f9ae7 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; 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 863cd6ce62b..55fbe1c5fcb 100644 --- a/crates/bindings-typescript/src/server/procedures.ts +++ b/crates/bindings-typescript/src/server/procedures.ts @@ -5,7 +5,7 @@ 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'; @@ -59,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, }); } }; @@ -91,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; @@ -157,7 +157,7 @@ function registerProcedure< sourceName: exportName, params: paramsType, returnType, - visibility: FunctionVisibility.ClientCallable, + visibility: rawVisibility(opts?.visibility), }); if (opts?.name != null) { 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/schema.ts b/crates/bindings-typescript/src/server/schema.ts index 95688b7f3cd..d88c49da605 100644 --- a/crates/bindings-typescript/src/server/schema.ts +++ b/crates/bindings-typescript/src/server/schema.ts @@ -409,7 +409,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; @@ -648,7 +651,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/tests/hosted_auth.test.ts b/crates/bindings-typescript/tests/hosted_auth.test.ts index 6eeb1af4e83..02d01c0809c 100644 --- a/crates/bindings-typescript/tests/hosted_auth.test.ts +++ b/crates/bindings-typescript/tests/hosted_auth.test.ts @@ -32,6 +32,16 @@ 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 }); @@ -131,13 +141,169 @@ describe('verified invocation authentication', () => { }); }); -describe('hosted authentication capability', () => { - it('advertises the updated bindings without changing function visibility', () => { +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([16, 0, 0, 0, 0]); + const environmentWriter = new BinaryWriter(8); + RawModuleDefV10Section.serialize(environmentWriter, { + tag: 'Environment', + value: [], + }); + expect([...environmentWriter.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 run = module.reducer(() => {}); - const inner = run[exportContext]!; - run[registerExport](inner, 'run'); - expect(inner.moduleDef.capabilities).toContain('hosted_auth_v1'); - expect(inner.moduleDef.reducers[0].visibility.tag).toBe('ClientCallable'); + 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/src/rt.rs b/crates/bindings/src/rt.rs index 89e29ae5891..a26cb221e1d 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()); 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/cli/src/subcommands/generate.rs b/crates/cli/src/subcommands/generate.rs index 14b39212458..e1b6caff37e 100644 --- a/crates/cli/src/subcommands/generate.rs +++ b/crates/cli/src/subcommands/generate.rs @@ -266,7 +266,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/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/invocation_flags_tests.rs b/crates/core/src/host/host_controller/invocation_flags_tests.rs index a140cf4d7d2..5abd02e1c6f 100644 --- a/crates/core/src/host/host_controller/invocation_flags_tests.rs +++ b/crates/core/src/host/host_controller/invocation_flags_tests.rs @@ -12,20 +12,16 @@ 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("private", 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("system_procedure", ProductType::unit(), AlgebraicType::U8); - let mut schema = schema.finish(); - for section in &mut schema.sections { - if let spacetimedb_lib::db::raw_def::v10::RawModuleDefV10Section::Reducers(reducers) = section { - reducers - .iter_mut() - .find(|r| &*r.source_name == "private") - .unwrap() - .visibility = FunctionVisibility::Private; - } - } - let schema = spacetimedb_lib::bsatn::to_vec(&spacetimedb_lib::RawModuleDef::V10(schema)).unwrap(); + 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!( @@ -51,7 +47,7 @@ fn program() -> Program { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn invocation_flags_are_host_owned_and_lifecycle_calls_remain_restricted() { +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(); @@ -95,7 +91,7 @@ async fn invocation_flags_are_host_owned_and_lifecycle_calls_remain_restricted() .outcome .into_result() .unwrap(); - for name in ["init"] { + for name in ["internal", "init"] { assert!(module .call_reducer(sender, None, None, None, None, name, FunctionArgs::Nullary) .await @@ -105,6 +101,11 @@ async fn invocation_flags_are_host_owned_and_lifecycle_calls_remain_restricted() .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) @@ -117,7 +118,7 @@ async fn invocation_flags_are_host_owned_and_lifecycle_calls_remain_restricted() // observes zero again even if the procedure instance is reused. let result = module .call_procedure_with_params( - "system_procedure", + "internal_procedure", CallProcedureParams::from_system( Timestamp::now(), database.database_identity, diff --git a/crates/lib/src/db/raw_def/v10.rs b/crates/lib/src/db/raw_def/v10.rs index c011551ff34..a06d1ea2171 100644 --- a/crates/lib/src/db/raw_def/v10.rs +++ b/crates/lib/src/db/raw_def/v10.rs @@ -338,6 +338,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))] @@ -347,11 +350,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. @@ -1120,10 +1143,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(), }); @@ -1144,12 +1177,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), }) } @@ -1193,6 +1237,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 { @@ -1203,7 +1260,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(), }); @@ -1598,6 +1655,8 @@ mod compatibility_tests { for (visibility, expected) in [ (FunctionVisibility::Private, 0), (FunctionVisibility::ClientCallable, 1), + (FunctionVisibility::Internal, 2), + (FunctionVisibility::ExplicitClientCallable, 3), ] { assert_eq!(bsatn::to_vec(&visibility).unwrap(), [expected]); } @@ -1636,7 +1695,26 @@ mod compatibility_tests { } #[test] - fn older_hosts_reject_new_capabilities() { + 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(); diff --git a/crates/schema/src/auto_migrate.rs b/crates/schema/src/auto_migrate.rs index 97fff428830..4a52e079670 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(|(_, _, 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)) + }); + 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 ad37d3e12b8..3817885ea78 100644 --- a/crates/schema/src/def.rs +++ b/crates/schema/src/def.rs @@ -1180,7 +1180,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() { @@ -1195,7 +1203,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() { @@ -2356,6 +2372,9 @@ pub enum FunctionVisibility { /// Callable from client code. ClientCallable, + + /// Callable only by a host-verified internal invocation. + Internal, } impl fmt::Display for FunctionVisibility { @@ -2363,6 +2382,7 @@ impl fmt::Display for FunctionVisibility { f.write_str(match self { Self::Private => "Private", Self::ClientCallable => "Public", + Self::Internal => "Internal", }) } } @@ -2371,9 +2391,13 @@ 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, } @@ -2388,7 +2412,10 @@ 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, } } } @@ -2404,6 +2431,7 @@ impl From for RawFunctionVisibility { match val { FunctionVisibility::Private => Self::Private, FunctionVisibility::ClientCallable => Self::ClientCallable, + FunctionVisibility::Internal => Self::Internal, } } } diff --git a/crates/schema/src/def/validate/v10.rs b/crates/schema/src/def/validate/v10.rs index 4e7872fd44d..2e4369821c2 100644 --- a/crates/schema/src/def/validate/v10.rs +++ b/crates/schema/src/def/validate/v10.rs @@ -105,6 +105,20 @@ pub fn validate(def: RawModuleDefV10) -> Result { } } } + // 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 environment = validate_environment(&def); 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); @@ -304,7 +318,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); @@ -434,12 +453,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 { @@ -451,7 +471,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 => { @@ -462,7 +487,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 => {} @@ -471,7 +501,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; } } @@ -1511,7 +1550,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 @@ -2849,9 +2888,218 @@ mod tests { } #[cfg(test)] -mod capability_tests { +mod visibility_tests { use super::*; - use spacetimedb_lib::db::raw_def::v10::RawModuleDefV10Builder; + 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(); @@ -2877,6 +3125,40 @@ mod capability_tests { 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); + } + } } #[cfg(test)] diff --git a/crates/schema/src/def/validate/v9.rs b/crates/schema/src/def/validate/v9.rs index b8b23e4d37e..1b546fbcfe7 100644 --- a/crates/schema/src/def/validate/v9.rs +++ b/crates/schema/src/def/validate/v9.rs @@ -392,7 +392,7 @@ impl ModuleValidatorV9<'_> { }, lifecycle, visibility: if lifecycle.is_some() { - FunctionVisibility::Private + FunctionVisibility::Internal } else { FunctionVisibility::ClientCallable }, diff --git a/crates/schema/src/error.rs b/crates/schema/src/error.rs index 52ee4996155..301678b0b01 100644 --- a/crates/schema/src/error.rs +++ b/crates/schema/src/error.rs @@ -26,6 +26,8 @@ pub enum ValidationError { 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("module has repeated environment declarations")] diff --git a/crates/testing/tests/invocation_flags.rs b/crates/testing/tests/invocation_flags.rs index 44d098efa5c..16581de6662 100644 --- a/crates/testing/tests/invocation_flags.rs +++ b/crates/testing/tests/invocation_flags.rs @@ -20,7 +20,7 @@ fn wasm_invocation_flags_do_not_infer_authority_from_identity_or_connection_abse .outcome .into_result() .unwrap(); - for name in ["init"] { + for name in ["internal", "init", "scheduled"] { assert!(module .call_reducer(sender, None, None, None, None, name, FunctionArgs::Nullary) .await @@ -30,6 +30,18 @@ fn wasm_invocation_flags_do_not_infer_authority_from_identity_or_connection_abse .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( 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 b293fd95c20..fdbb2822237 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 @@ -450,7 +450,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). diff --git a/modules/invocation-flags-test/src/lib.rs b/modules/invocation-flags-test/src/lib.rs index d4f3000b0dd..9621ef95f59 100644 --- a/modules/invocation-flags-test/src/lib.rs +++ b/modules/invocation-flags-test/src/lib.rs @@ -14,6 +14,14 @@ pub fn external(ctx: &ReducerContext) { 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()); @@ -27,6 +35,12 @@ pub fn external_procedure(ctx: &mut ProcedureContext) -> bool { 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] @@ -49,7 +63,7 @@ pub fn schedule(ctx: &ReducerContext) { }); } -#[spacetimedb::reducer] +#[spacetimedb::reducer(internal)] pub fn scheduled(ctx: &ReducerContext, job: Job) { assert!(ctx.sender_auth().is_internal()); assert_eq!(ctx.sender(), ctx.database_identity()); diff --git a/sdks/rust/tests/connect_disconnect_client/src/module_bindings/identity_connected_reducer.rs b/sdks/rust/tests/connect_disconnect_client/src/module_bindings/identity_connected_reducer.rs deleted file mode 100644 index cfbf1d03d30..00000000000 --- a/sdks/rust/tests/connect_disconnect_client/src/module_bindings/identity_connected_reducer.rs +++ /dev/null @@ -1,62 +0,0 @@ -// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE -// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. - -#![allow(unused, clippy::all)] -use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; - -#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] -#[sats(crate = __lib)] -pub(super) struct IdentityConnectedArgs {} - -impl From for super::Reducer { - fn from(args: IdentityConnectedArgs) -> Self { - Self::IdentityConnected - } -} - -impl __sdk::InModule for IdentityConnectedArgs { - type Module = super::RemoteModule; -} - -#[allow(non_camel_case_types)] -/// Extension trait for access to the reducer `identity_connected`. -/// -/// Implemented for [`super::RemoteReducers`]. -pub trait identity_connected { - /// Request that the remote module invoke the reducer `identity_connected` to run as soon as possible. - /// - /// This method returns immediately, and errors only if we are unable to send the request. - /// The reducer will run asynchronously in the future, - /// and this method provides no way to listen for its completion status. - /// /// Use [`identity_connected:identity_connected_then`] to run a callback after the reducer completes. - fn identity_connected(&self) -> __sdk::Result<()> { - self.identity_connected_then(|_, _| {}) - } - - /// Request that the remote module invoke the reducer `identity_connected` to run as soon as possible, - /// registering `callback` to run when we are notified that the reducer completed. - /// - /// This method returns immediately, and errors only if we are unable to send the request. - /// The reducer will run asynchronously in the future, - /// and its status can be observed with the `callback`. - fn identity_connected_then( - &self, - - callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) - + Send - + 'static, - ) -> __sdk::Result<()>; -} - -impl identity_connected for super::RemoteReducers { - fn identity_connected_then( - &self, - - callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) - + Send - + 'static, - ) -> __sdk::Result<()> { - self.imp - .invoke_reducer_with_callback(IdentityConnectedArgs {}, callback) - } -} diff --git a/sdks/rust/tests/connect_disconnect_client/src/module_bindings/identity_disconnected_reducer.rs b/sdks/rust/tests/connect_disconnect_client/src/module_bindings/identity_disconnected_reducer.rs deleted file mode 100644 index 8cec050f73e..00000000000 --- a/sdks/rust/tests/connect_disconnect_client/src/module_bindings/identity_disconnected_reducer.rs +++ /dev/null @@ -1,62 +0,0 @@ -// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE -// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. - -#![allow(unused, clippy::all)] -use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; - -#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] -#[sats(crate = __lib)] -pub(super) struct IdentityDisconnectedArgs {} - -impl From for super::Reducer { - fn from(args: IdentityDisconnectedArgs) -> Self { - Self::IdentityDisconnected - } -} - -impl __sdk::InModule for IdentityDisconnectedArgs { - type Module = super::RemoteModule; -} - -#[allow(non_camel_case_types)] -/// Extension trait for access to the reducer `identity_disconnected`. -/// -/// Implemented for [`super::RemoteReducers`]. -pub trait identity_disconnected { - /// Request that the remote module invoke the reducer `identity_disconnected` to run as soon as possible. - /// - /// This method returns immediately, and errors only if we are unable to send the request. - /// The reducer will run asynchronously in the future, - /// and this method provides no way to listen for its completion status. - /// /// Use [`identity_disconnected:identity_disconnected_then`] to run a callback after the reducer completes. - fn identity_disconnected(&self) -> __sdk::Result<()> { - self.identity_disconnected_then(|_, _| {}) - } - - /// Request that the remote module invoke the reducer `identity_disconnected` to run as soon as possible, - /// registering `callback` to run when we are notified that the reducer completed. - /// - /// This method returns immediately, and errors only if we are unable to send the request. - /// The reducer will run asynchronously in the future, - /// and its status can be observed with the `callback`. - fn identity_disconnected_then( - &self, - - callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) - + Send - + 'static, - ) -> __sdk::Result<()>; -} - -impl identity_disconnected for super::RemoteReducers { - fn identity_disconnected_then( - &self, - - callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) - + Send - + 'static, - ) -> __sdk::Result<()> { - self.imp - .invoke_reducer_with_callback(IdentityDisconnectedArgs {}, callback) - } -} diff --git a/sdks/rust/tests/connect_disconnect_client/src/module_bindings/mod.rs b/sdks/rust/tests/connect_disconnect_client/src/module_bindings/mod.rs index bae06001c05..b6ae9d07827 100644 --- a/sdks/rust/tests/connect_disconnect_client/src/module_bindings/mod.rs +++ b/sdks/rust/tests/connect_disconnect_client/src/module_bindings/mod.rs @@ -10,15 +10,11 @@ pub mod connected_table; pub mod connected_type; pub mod disconnected_table; pub mod disconnected_type; -pub mod identity_connected_reducer; -pub mod identity_disconnected_reducer; pub use connected_table::*; pub use connected_type::Connected; pub use disconnected_table::*; pub use disconnected_type::Disconnected; -pub use identity_connected_reducer::identity_connected; -pub use identity_disconnected_reducer::identity_disconnected; #[derive(Clone, PartialEq, Debug)] @@ -27,10 +23,7 @@ pub use identity_disconnected_reducer::identity_disconnected; /// Contained within a [`__sdk::ReducerEvent`] in [`EventContext`]s for reducer events /// to indicate which reducer caused the event. -pub enum Reducer { - IdentityConnected, - IdentityDisconnected, -} +pub enum Reducer {} impl __sdk::InModule for Reducer { type Module = RemoteModule; @@ -39,18 +32,12 @@ impl __sdk::InModule for Reducer { impl __sdk::Reducer for Reducer { fn reducer_name(&self) -> &'static str { match self { - Reducer::IdentityConnected => "identity_connected", - Reducer::IdentityDisconnected => "identity_disconnected", _ => unreachable!(), } } #[allow(clippy::clone_on_copy)] fn args_bsatn(&self) -> Result, __sats::bsatn::EncodeError> { match self { - Reducer::IdentityConnected => __sats::bsatn::to_vec(&identity_connected_reducer::IdentityConnectedArgs {}), - Reducer::IdentityDisconnected => { - __sats::bsatn::to_vec(&identity_disconnected_reducer::IdentityDisconnectedArgs {}) - } _ => unreachable!(), } }