diff --git a/docs/.cursor/rules/spacetimedb.md b/docs/.cursor/rules/spacetimedb.md
new file mode 100644
index 00000000000..2bf12e8dbae
--- /dev/null
+++ b/docs/.cursor/rules/spacetimedb.md
@@ -0,0 +1,4 @@
+- Before implementing SpacetimeDB code, reference the LLMs text and ensure you are using the proper syntax.
+- If you aren't certain about the SpacetimeDB implementation, ask the user
+- You're executing commands in Powershell, so make sure if you want to execute multiple commands to use ; instead of &&
+- All your commands that you execute start from the project directory, so make sure you take that into account when you're navigating directories.
\ No newline at end of file
diff --git a/docs/llms.md b/docs/llms.md
index fa5e15ec958..27dee7d9b89 100644
--- a/docs/llms.md
+++ b/docs/llms.md
@@ -807,46 +807,215 @@ Refer to the [official Rust Module SDK documentation on docs.rs](https://docs.rs
Scheduled reducer calls originate from the SpacetimeDB scheduler itself, not from an external client connection. Therefore, within a scheduled reducer, `ctx.sender` will be the module's own identity, and `ctx.connection_id` will be `None`.
:::
-#### Row-Level Security (Client Visibility Filters)
+#### Row-Level Security (RLS)
-(Unstable Feature)
+Row Level Security (RLS) allows module authors to restrict client access to specific rows
+of tables that are marked as `public`. By default, tables *without* the `public`
+attribute are private and completely inaccessible to clients. Tables *with* the `public`
+attribute are, by default, fully visible to any client that subscribes to them. RLS provides
+a mechanism to selectively restrict access to certain rows of these `public` tables based
+on rules evaluated for each client.
-SpacetimeDB allows defining row-level security rules using the `#[spacetimedb::client_visibility_filter]` attribute. This attribute is applied to a `const` binding of type `Filter` and defines an SQL-like query that determines which rows of a table are visible to clients making subscription requests.
+Private tables (those *without* the `public` attribute) are always completely inaccessible
+to clients, and RLS rules do not apply to them. RLS rules are defined for `public` tables
+to filter which rows are visible.
-* The query uses `:sender` to refer to the identity of the subscribing client.
-* Multiple filters on the same table are combined with `OR` logic.
-* Query errors (syntax, type errors, unknown tables) are reported during `spacetime publish`.
+These access-granting rules are expressed in SQL and evaluated automatically for queries
+and subscriptions made by clients against private tables with associated RLS rules.
+
+:::info Version-Specific Status
+Row-Level Security (RLS) was introduced as an **unstable** feature in **SpacetimeDB v1.1.0**.
+It requires explicit opt-in via feature flags or pragmas.
+:::
+
+**Enabling RLS**
+
+RLS is currently **unstable** and must be explicitly enabled in your module.
+
+To enable RLS, activate the `unstable` feature in your project's `Cargo.toml`:
+
+```toml
+spacetimedb = { version = "1.1.0", features = ["unstable"] } # at least version 1.1.0
+```
+
+**How It Works**
+
+RLS rules are attached to `public` tables (tables with `#[table(..., public)]`)
+and are expressed in SQL using constants of type `Filter`.
```rust
use spacetimedb::{client_visibility_filter, Filter, table, Identity};
-#[table(name = "location_state")]
-struct LocationState { #[primary_key] entity_id: u64, chunk_index: u32 }
-#[table(name = "user_state")]
-struct UserState { #[primary_key] identity: Identity, entity_id: u64 }
+// Define a public table for RLS
+#[table(name = account, public)] // Now a public table
+struct Account {
+ #[primary_key]
+ identity: Identity,
+ email: String,
+ balance: u32,
+}
-/// Players can only see entities located in the same chunk as their own entity.
+/// RLS Rule: Allow a client to see *only* their own account record.
#[client_visibility_filter]
-const PLAYERS_SEE_ENTITIES_IN_SAME_CHUNK: Filter = Filter::Sql("
- SELECT * FROM LocationState WHERE chunk_index IN (
- SELECT chunk_index FROM LocationState WHERE entity_id IN (
- SELECT entity_id FROM UserState WHERE identity = :sender
- )
- )
+const ACCOUNT_VISIBILITY: Filter = Filter::Sql(
+ // This query is evaluated per client request.
+ // :sender is automatically bound to the requesting client's identity.
+ // Only rows matching this filter are returned to the client from the public 'account' table,
+ // overriding its default full visibility for matching clients.
+ "SELECT * FROM account WHERE identity = :sender"
+);
+```
+
+A module will fail to publish if any of its RLS rules are invalid or malformed.
+
+**`:sender`**
+
+You can use the special `:sender` parameter in your rules for user-specific access control.
+This parameter is automatically bound to the requesting client's [Identity](#identity).
+
+Note that module owners have unrestricted access to all tables, including all rows of
+`public` tables (bypassing RLS rules) and `private` tables.
+
+**Semantic Constraints**
+
+RLS rules act as filters defining which rows of a `public` table are visible to a client.
+Like subscriptions, arbitrary column projections are **not** allowed.
+Joins **are** allowed (e.g., to check permissions in another table), but each rule must
+ultimately return rows from the single public table it applies to.
+
+**Multiple Rules Per Table**
+
+Multiple RLS rules may be declared for the same `public` table. They are evaluated as a
+logical `OR`, meaning clients can see any row that matches at least one rule.
+
+**Example (Building on previous Account table)**
+
+```rust
+# use spacetimedb::{client_visibility_filter, Filter, table, Identity};
+# #[table(name = account)] struct Account { #[primary_key] identity: Identity, email: String, balance: u32 }
+// Assume an 'admin' table exists to track administrator identities
+#[table(name = admin)] struct Admin { #[primary_key] identity: Identity }
+
+/// RLS Rule 1: A client can see their own account.
+#[client_visibility_filter]
+const ACCOUNT_OWNER_VISIBILITY: Filter = Filter::Sql(
+ "SELECT * FROM account WHERE identity = :sender"
+);
+
+/// RLS Rule 2: An admin client can see *all* accounts.
+#[client_visibility_filter]
+const ACCOUNT_ADMIN_VISIBILITY: Filter = Filter::Sql(
+ // This join checks if the requesting client (:sender) exists in the admin table.
+ // If they do, the join succeeds, and all rows from 'account' are potentially visible.
+ "SELECT account.* FROM account JOIN admin WHERE admin.identity = :sender"
+);
+
+// Result: A non-admin client sees only their own account row.
+// An admin client sees all account rows because they match the second rule.
+```
+
+**Recursive Application**
+
+RLS rules can reference other tables that might *also* have RLS rules. These rules are applied recursively.
+For instance, if Rule A depends on Table B, and Table B has its own RLS rules, a client only gets results
+from Rule A if they also have permission to see the relevant rows in Table B according to Table B's rules.
+This ensures that the intended row visibility on `public` tables is maintained even through indirect access patterns.
+
+**Example (Building on previous Account/Admin tables)**
+
+```rust
+# use spacetimedb::{client_visibility_filter, Filter, table, Identity};
+# #[table(name = account)] struct Account { #[primary_key] identity: Identity, email: String, balance: u32 }
+# #[table(name = admin)] struct Admin { #[primary_key] identity: Identity }
+// Define a private player table linked to account
+#[table(name = player)] // Private table
+struct Player { #[primary_key] id: Identity, level: u32 }
+
+# /// RLS Rule 1: A client can see their own account.
+# #[client_visibility_filter] const ACCOUNT_OWNER_VISIBILITY: Filter = Filter::Sql( "SELECT * FROM account WHERE identity = :sender" );
+# /// RLS Rule 2: An admin client can see *all* accounts.
+# #[client_visibility_filter] const ACCOUNT_ADMIN_VISIBILITY: Filter = Filter::Sql( "SELECT account.* FROM account JOIN admin WHERE admin.identity = :sender" );
+
+/// RLS Rule for Player table: Players are visible if the associated account is visible.
+#[client_visibility_filter]
+const PLAYER_VISIBILITY: Filter = Filter::Sql(
+ // This rule joins Player with Account.
+ // Crucially, the client running this query must *also* satisfy the RLS rules
+ // defined for the `account` table for the specific account row being joined.
+ // Therefore, non-admins see only their own player, admins see all players.
+ "SELECT p.* FROM account a JOIN player p ON a.identity = p.id"
+);
+```
+
+Self-joins are allowed within RLS rules. However, RLS rules cannot be mutually recursive
+(e.g., Rule A depends on Table B, and Rule B depends on Table A), as this would cause
+infinite recursion during evaluation.
+
+**Example: Self-Join (Valid)**
+
+```rust
+# use spacetimedb::{client_visibility_filter, Filter, table, Identity};
+# #[table(name = player)] struct Player { #[primary_key] id: Identity, level: u32 }
+# // Dummy account table for join context
+# #[table(name = account)] struct Account { #[primary_key] identity: Identity }
+
+/// RLS Rule: A client can see other players at the same level as their own player.
+#[client_visibility_filter]
+const PLAYER_SAME_LEVEL_VISIBILITY: Filter = Filter::Sql("
+ SELECT q.*
+ FROM account a -- Find the requester's account
+ JOIN player p ON a.identity = p.id -- Find the requester's player
+ JOIN player q on p.level = q.level -- Find other players (q) at the same level
+ WHERE a.identity = :sender -- Ensure we start with the requester
");
```
-:::info Version-Specific Status and Usage
+**Example: Mutually Recursive Rules (Invalid)**
-* **SpacetimeDB 1.0:** The Row-Level Security feature was not fully implemented or enforced in version 1.0. Modules developed for SpacetimeDB 1.0 should **not** use this feature.
-* **SpacetimeDB 1.1:** The feature is available but considered **unstable** in version 1.1. To use it, you must explicitly opt-in by enabling the `unstable` feature flag for the `spacetimedb` crate in your module's `Cargo.toml`:
- ```toml
- [dependencies]
- spacetimedb = { version = "1.1", features = ["unstable"] }
- # ... other dependencies
- ```
- Modules developed for 1.1 can use row-level security only if this feature flag is enabled.
-:::
+This module would fail to publish because the `ACCOUNT_NEEDS_PLAYER` rule depends on the
+`player` table, while the `PLAYER_NEEDS_ACCOUNT` rule depends on the `account` table.
+
+```rust
+use spacetimedb::{client_visibility_filter, Filter, table, Identity};
+
+#[table(name = account)] struct Account { #[primary_key] id: u64, identity: Identity }
+#[table(name = player)] struct Player { #[primary_key] id: u64 }
+
+/// RLS: An account is visible only if a corresponding player exists.
+#[client_visibility_filter]
+const ACCOUNT_NEEDS_PLAYER: Filter = Filter::Sql(
+ "SELECT a.* FROM account a JOIN player p ON a.id = p.id WHERE a.identity = :sender"
+);
+
+/// RLS: A player is visible only if a corresponding account exists.
+#[client_visibility_filter]
+const PLAYER_NEEDS_ACCOUNT: Filter = Filter::Sql(
+ // This rule requires access to 'account', which itself requires access to 'player' -> recursion!
+ "SELECT p.* FROM account a JOIN player p ON a.id = p.id WHERE a.identity = :sender"
+);
+```
+
+**Usage in Subscriptions**
+
+When a client subscribes to a `public` table that has RLS rules defined,
+the server automatically applies those rules. The subscription results (both initial
+and subsequent updates) will only contain rows that the specific client is allowed to
+see based on the RLS rules evaluating successfully for that client.
+
+While the SQL constraints and limitations outlined in the [SQL reference docs](/docs/sql/index.md#subscriptions)
+(like limitations on complex joins or aggregations) do not apply directly to the definition
+of RLS rules themselves, these constraints *do* apply to client subscriptions that *use* those rules.
+For example, an RLS rule might use a complex join not normally supported in subscriptions.
+If a client tries to subscribe directly to the table governed by that complex RLS rule,
+the subscription itself might fail, even if the RLS rule is valid for direct queries.
+
+**Best Practices**
+
+1. Define RLS rules for `public` tables where you need to restrict row visibility for different clients.
+2. Use `:sender` for client-specific filtering within your rules.
+3. Keep RLS rules as simple as possible while enforcing desired access.
+4. Be mindful of potential performance implications of complex joins in RLS rules, especially when combined with subscriptions.
+5. Follow the general [SQL best practices](/docs/sql/index.md#best-practices-for-performance-and-scalability) for optimizing your RLS rules.
### Client SDK (Rust)
@@ -1519,7 +1688,7 @@ public static partial class Module
[Reducer]
public static void SendMessage(ReducerContext ctx, SendMessageSchedule scheduleArgs)
{
- // Security check!
+ // Security check is important!
if (!ctx.Sender.Equals(ctx.Identity))
{
throw new Exception("Reducer SendMessage may not be invoked by clients, only via scheduling.");
@@ -1596,6 +1765,217 @@ Throwing an unhandled exception within a C# reducer will cause the transaction t
It's generally good practice to validate input and state early in the reducer and `throw` specific exceptions for handled error conditions.
+#### Row-Level Security (RLS)
+
+Row Level Security (RLS) allows module authors to restrict which rows of a public table each client can access.
+These access rules are expressed in SQL and evaluated automatically for queries and subscriptions.
+
+:::info Version-Specific Status
+Row-Level Security (RLS) was introduced as an **unstable** feature in **SpacetimeDB v1.1.0**.
+It requires explicit opt-in via feature flags or pragmas.
+:::
+
+**Enabling RLS**
+
+RLS is currently **unstable** and must be explicitly enabled in your module.
+
+To enable RLS, include the following preprocessor directive at the top of your module files:
+
+```cs
+#pragma warning disable STDB_UNSTABLE
+```
+
+**How It Works**
+
+RLS rules are attached to `public` tables (tables with `#[table(..., public)]`)
+and are expressed in SQL using public static readonly fields of type `Filter` annotated with
+`[SpacetimeDB.ClientVisibilityFilter]`.
+
+```cs
+using SpacetimeDB;
+
+#pragma warning disable STDB_UNSTABLE
+
+// Define a public table for RLS
+[Table(Name = "account", Public = true)] // Ensures correct C# syntax for public table
+public partial class Account
+{
+ [PrimaryKey] public Identity Identity;
+ public string Email = "";
+ public uint Balance;
+}
+
+public partial class Module
+{
+ ///
+ /// RLS Rule: Allow a client to see *only* their own account record.
+ /// This rule applies to the public 'account' table.
+ ///
+ [SpacetimeDB.ClientVisibilityFilter]
+ public static readonly Filter ACCOUNT_VISIBILITY = new Filter.Sql(
+ // This query is evaluated per client request.
+ // :sender is automatically bound to the requesting client's identity.
+ // Only rows matching this filter are returned to the client from the public 'account' table,
+ // overriding its default full visibility for matching clients.
+ "SELECT * FROM account WHERE identity = :sender"
+ );
+}
+```
+
+A module will fail to publish if any of its RLS rules are invalid or malformed.
+
+**`:sender`**
+
+You can use the special `:sender` parameter in your rules for user specific access control.
+This parameter is automatically bound to the requesting client's [Identity](#identity).
+
+Note that module owners have unrestricted access to all tables, including all rows of
+`public` tables (bypassing RLS rules) and `private` tables.
+
+**Semantic Constraints**
+
+RLS rules are similar to subscriptions in that logically they act as filters on a particular table.
+Also like subscriptions, arbitrary column projections are **not** allowed.
+Joins **are** allowed, but each rule must return rows from one and only one table.
+
+**Multiple Rules Per Table**
+
+Multiple rules may be declared for the same `public` table. They are evaluated as a logical `OR`.
+This means clients will be able to see to any row that matches at least one rule.
+
+**Example**
+
+```cs
+using SpacetimeDB;
+
+#pragma warning disable STDB_UNSTABLE
+
+public partial class Module
+{
+ ///
+ /// A client can only see their account.
+ ///
+ [SpacetimeDB.ClientVisibilityFilter]
+ public static readonly Filter ACCOUNT_FILTER = new Filter.Sql(
+ "SELECT * FROM account WHERE identity = :sender"
+ );
+
+ ///
+ /// An admin can see all accounts.
+ ///
+ [SpacetimeDB.ClientVisibilityFilter]
+ public static readonly Filter ACCOUNT_FILTER_FOR_ADMINS = new Filter.Sql(
+ "SELECT account.* FROM account JOIN admin WHERE admin.identity = :sender"
+ );
+}
+```
+
+**Recursive Application**
+
+RLS rules can reference other tables with RLS rules, and they will be applied recursively.
+This ensures that data is never leaked through indirect access patterns.
+
+**Example**
+
+```cs
+using SpacetimeDB;
+
+public partial class Module
+{
+ ///
+ /// A client can only see their account.
+ ///
+ [SpacetimeDB.ClientVisibilityFilter]
+ public static readonly Filter ACCOUNT_FILTER = new Filter.Sql(
+ "SELECT * FROM account WHERE identity = :sender"
+ );
+
+ ///
+ /// An admin can see all accounts.
+ ///
+ [SpacetimeDB.ClientVisibilityFilter]
+ public static readonly Filter ACCOUNT_FILTER_FOR_ADMINS = new Filter.Sql(
+ "SELECT account.* FROM account JOIN admin WHERE admin.identity = :sender"
+ );
+
+ ///
+ /// Explicitly filtering by client identity in this rule is not necessary,
+ /// since the above RLS rules on `account` will be applied automatically.
+ /// Hence a client can only see their player, but an admin can see all players.
+ ///
+ [SpacetimeDB.ClientVisibilityFilter]
+ public static readonly Filter PLAYER_FILTER = new Filter.Sql(
+ "SELECT p.* FROM account a JOIN player p ON a.id = p.id"
+ );
+}
+```
+
+And while self-joins are allowed, in general RLS rules cannot be self-referential,
+as this would result in infinite recursion.
+
+**Example: Self-Join**
+
+```cs
+using SpacetimeDB;
+
+public partial class Module
+{
+ ///
+ /// A client can only see players on their same level.
+ ///
+ [SpacetimeDB.ClientVisibilityFilter]
+ public static readonly Filter PLAYER_FILTER = new Filter.Sql(@"
+ SELECT q.*
+ FROM account a
+ JOIN player p ON u.id = p.id
+ JOIN player q on p.level = q.level
+ WHERE a.identity = :sender
+ ");
+}
+```
+
+**Example: Recursive Rules**
+
+This module will fail to publish because each rule depends on the other one.
+
+```cs
+using SpacetimeDB;
+
+public partial class Module
+{
+ ///
+ /// An account must have a corresponding player.
+ ///
+ [SpacetimeDB.ClientVisibilityFilter]
+ public static readonly Filter ACCOUNT_FILTER = new Filter.Sql(
+ "SELECT a.* FROM account a JOIN player p ON a.id = p.id WHERE a.identity = :sender"
+ );
+
+ ///
+ /// A player must have a corresponding account.
+ ///
+ [SpacetimeDB.ClientVisibilityFilter]
+ public static readonly Filter ACCOUNT_FILTER = new Filter.Sql(
+ "SELECT p.* FROM account a JOIN player p ON a.id = p.id WHERE a.identity = :sender"
+ );
+}
+```
+
+**Usage in Subscriptions**
+
+RLS rules automatically apply to subscriptions so that if a client subscribes to a table with RLS filters,
+the subscription will only return rows that the client is allowed to see.
+
+While the constraints and limitations outlined in the [SQL reference docs](/docs/sql/index.md#subscriptions) do not apply to RLS rules,
+they do apply to the subscriptions that use them.
+For example, it is valid for an RLS rule to have more joins than are supported by subscriptions.
+However a client will not be able to subscribe to the table for which that rule is defined.
+
+**Best Practices**
+
+1. Use `:sender` for client specific filtering.
+2. Follow the [SQL best practices](/docs/sql/index.md#best-practices-for-performance-and-scalability) for optimizing your RLS rules.
+
### Client SDK (C#)
This section details how to build native C# client applications (including Unity games) that interact with a SpacetimeDB module.
@@ -1747,11 +2127,11 @@ private void SubscribeToTables(DbConnection conn)
{
Console.WriteLine("Subscribing to tables...");
conn.SubscriptionBuilder()
- .OnApplied(OnSubscriptionApplied)
+ .OnApplied(on_subscription_applied)
.OnError((errCtx, err) => {
Console.WriteLine($"Subscription failed: {err.Message}");
})
- // Example: Subscribe to all rows from 'Player' and 'Message' tables
+ // Example: Subscribe to all rows from 'player' and 'message' tables
.Subscribe(new string[] { "SELECT * FROM Player", "SELECT * FROM Message" });
}
@@ -2152,6 +2532,15 @@ In TypeScript, the first argument (`ctx: EventContext | undefined`) to row callb
Call reducers via `conn.reducers`. Register callbacks via `conn.reducers.onReducerName(...)` to observe outcomes.
+* **Invoking:** Access generated reducer functions via `conn.reducers.reducerName(arg1, arg2, ...)`. Calling these functions sends the request to the server.
+* **Reducer Callbacks:** Register callbacks using `conn.reducers.onReducerName((ctx: ReducerEventContext, arg1, ...) => { ... })` to react to the *outcome* of reducer calls initiated by *any* client (including your own).
+* **ReducerEventContext (`ctx`)**: Contains information about the completed reducer call:
+ * `ctx.event.reducer`: The specific reducer variant record and its arguments.
+ * `ctx.event.status`: An object indicating the outcome. Check `ctx.event.status.tag` which will be a string like `"Committed"` or `"Failed"`. If failed, the reason is typically in `ctx.event.status.value`.
+ * `ctx.event.callerIdentity`: The `Identity` of the client that originally invoked the reducer.
+ * `ctx.event.message`: Contains the failure message if `ctx.event.status.tag === "Failed"`.
+ * `ctx.event.timestamp`, etc.
+
```typescript
// Part of the ChatClient class
private registerReducerCallbacks() {
@@ -2165,15 +2554,18 @@ private registerReducerCallbacks() {
}
private handleSendMessageResult(ctx: ReducerEventContext, messageText: string) {
+ // Check if this callback corresponds to a call made by this client instance
const wasOurCall = ctx.event.callerIdentity.isEqual(this.identity);
- if (!wasOurCall) return; // Only care about our own calls here
+ if (!wasOurCall) return; // Optional: Only react to your own calls
switch(ctx.event.status.tag) {
case "Committed":
console.log(`Our message "${messageText}" sent successfully.`);
break;
case "Failed":
- console.error(`Failed to send "${messageText}": ${ctx.event.status.value}`);
+ // Access the error message via status.value or event.message
+ const errorMessage = ctx.event.status.value || ctx.event.message || "Unknown error";
+ console.error(`Failed to send "${messageText}": ${errorMessage}`);
break;
case "OutOfEnergy":
console.error(`Failed to send "${messageText}": Out of Energy!`);