diff --git a/frontend/src/lib/spacetime/connection.svelte.ts b/frontend/src/lib/spacetime/connection.svelte.ts index dad524f..ba16d77 100644 --- a/frontend/src/lib/spacetime/connection.svelte.ts +++ b/frontend/src/lib/spacetime/connection.svelte.ts @@ -63,6 +63,7 @@ function connect(token?: string): DbConnection { .subscribe([ 'SELECT * FROM node', 'SELECT * FROM edge', + 'SELECT * FROM node_access', ]); }) .onConnectError((_ctx: ErrorContext, err: Error) => { diff --git a/frontend/src/lib/spacetime/index.ts b/frontend/src/lib/spacetime/index.ts index ef6daf9..b0ddd40 100644 --- a/frontend/src/lib/spacetime/index.ts +++ b/frontend/src/lib/spacetime/index.ts @@ -6,5 +6,5 @@ */ export { stdb, connectionState } from './connection.svelte'; -export { nodeStore, edgeStore } from './stores.svelte'; -export type { Node, Edge } from './module_bindings/types'; +export { nodeStore, edgeStore, nodeAccessStore, nodeVisibility } from './stores.svelte'; +export type { Node, Edge, NodeAccess } from './module_bindings/types'; diff --git a/frontend/src/lib/spacetime/module_bindings/delete_node_access_for_subject_reducer.ts b/frontend/src/lib/spacetime/module_bindings/delete_node_access_for_subject_reducer.ts new file mode 100644 index 0000000..c6bff5c --- /dev/null +++ b/frontend/src/lib/spacetime/module_bindings/delete_node_access_for_subject_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + subjectId: __t.string(), +}; diff --git a/frontend/src/lib/spacetime/module_bindings/delete_node_access_reducer.ts b/frontend/src/lib/spacetime/module_bindings/delete_node_access_reducer.ts new file mode 100644 index 0000000..e2c1041 --- /dev/null +++ b/frontend/src/lib/spacetime/module_bindings/delete_node_access_reducer.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + subjectId: __t.string(), + objectId: __t.string(), +}; diff --git a/frontend/src/lib/spacetime/module_bindings/index.ts b/frontend/src/lib/spacetime/module_bindings/index.ts index e49a151..117bdd4 100644 --- a/frontend/src/lib/spacetime/module_bindings/index.ts +++ b/frontend/src/lib/spacetime/module_bindings/index.ts @@ -39,14 +39,18 @@ import CreateEdgeReducer from "./create_edge_reducer"; import CreateNodeReducer from "./create_node_reducer"; import DeleteEdgeReducer from "./delete_edge_reducer"; import DeleteNodeReducer from "./delete_node_reducer"; +import DeleteNodeAccessReducer from "./delete_node_access_reducer"; +import DeleteNodeAccessForSubjectReducer from "./delete_node_access_for_subject_reducer"; import UpdateEdgeReducer from "./update_edge_reducer"; import UpdateNodeReducer from "./update_node_reducer"; +import UpsertNodeAccessReducer from "./upsert_node_access_reducer"; // Import all procedure arg schemas // Import all table schema definitions import EdgeRow from "./edge_table"; import NodeRow from "./node_table"; +import NodeAccessRow from "./node_access_table"; /** Type-only namespace exports for generated type groups. */ @@ -80,6 +84,23 @@ const tablesSchema = __schema({ { name: 'node_id_key', constraint: 'unique', columns: ['id'] }, ], }, NodeRow), + node_access: __table({ + name: 'node_access', + indexes: [ + { accessor: 'id', name: 'node_access_id_idx_btree', algorithm: 'btree', columns: [ + 'id', + ] }, + { accessor: 'object_id', name: 'node_access_object_id_idx_btree', algorithm: 'btree', columns: [ + 'objectId', + ] }, + { accessor: 'subject_id', name: 'node_access_subject_id_idx_btree', algorithm: 'btree', columns: [ + 'subjectId', + ] }, + ], + constraints: [ + { name: 'node_access_id_key', constraint: 'unique', columns: ['id'] }, + ], + }, NodeAccessRow), }); /** The schema information for all reducers in this module. This is defined the same way as the reducers would have been defined in the server, except the body of the reducer is omitted in code generation. */ @@ -89,8 +110,11 @@ const reducersSchema = __reducers( __reducerSchema("create_node", CreateNodeReducer), __reducerSchema("delete_edge", DeleteEdgeReducer), __reducerSchema("delete_node", DeleteNodeReducer), + __reducerSchema("delete_node_access", DeleteNodeAccessReducer), + __reducerSchema("delete_node_access_for_subject", DeleteNodeAccessForSubjectReducer), __reducerSchema("update_edge", UpdateEdgeReducer), __reducerSchema("update_node", UpdateNodeReducer), + __reducerSchema("upsert_node_access", UpsertNodeAccessReducer), ); /** The schema information for all procedures in this module. This is defined the same way as the procedures would have been defined in the server. */ diff --git a/frontend/src/lib/spacetime/module_bindings/node_access_table.ts b/frontend/src/lib/spacetime/module_bindings/node_access_table.ts new file mode 100644 index 0000000..3bb5d6e --- /dev/null +++ b/frontend/src/lib/spacetime/module_bindings/node_access_table.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + id: __t.string().primaryKey(), + subjectId: __t.string().name("subject_id"), + objectId: __t.string().name("object_id"), + access: __t.string(), + viaEdge: __t.string().name("via_edge"), +}); diff --git a/frontend/src/lib/spacetime/module_bindings/types.ts b/frontend/src/lib/spacetime/module_bindings/types.ts index dc11d5a..d485d4f 100644 --- a/frontend/src/lib/spacetime/module_bindings/types.ts +++ b/frontend/src/lib/spacetime/module_bindings/types.ts @@ -34,3 +34,12 @@ export const Node = __t.object("Node", { }); export type Node = __Infer; +export const NodeAccess = __t.object("NodeAccess", { + id: __t.string(), + subjectId: __t.string(), + objectId: __t.string(), + access: __t.string(), + viaEdge: __t.string(), +}); +export type NodeAccess = __Infer; + diff --git a/frontend/src/lib/spacetime/module_bindings/types/reducers.ts b/frontend/src/lib/spacetime/module_bindings/types/reducers.ts index c15d1c7..788883d 100644 --- a/frontend/src/lib/spacetime/module_bindings/types/reducers.ts +++ b/frontend/src/lib/spacetime/module_bindings/types/reducers.ts @@ -11,14 +11,20 @@ import CreateEdgeReducer from "../create_edge_reducer"; import CreateNodeReducer from "../create_node_reducer"; import DeleteEdgeReducer from "../delete_edge_reducer"; import DeleteNodeReducer from "../delete_node_reducer"; +import DeleteNodeAccessReducer from "../delete_node_access_reducer"; +import DeleteNodeAccessForSubjectReducer from "../delete_node_access_for_subject_reducer"; import UpdateEdgeReducer from "../update_edge_reducer"; import UpdateNodeReducer from "../update_node_reducer"; +import UpsertNodeAccessReducer from "../upsert_node_access_reducer"; export type ClearAllParams = __Infer; export type CreateEdgeParams = __Infer; export type CreateNodeParams = __Infer; export type DeleteEdgeParams = __Infer; export type DeleteNodeParams = __Infer; +export type DeleteNodeAccessParams = __Infer; +export type DeleteNodeAccessForSubjectParams = __Infer; export type UpdateEdgeParams = __Infer; export type UpdateNodeParams = __Infer; +export type UpsertNodeAccessParams = __Infer; diff --git a/frontend/src/lib/spacetime/module_bindings/upsert_node_access_reducer.ts b/frontend/src/lib/spacetime/module_bindings/upsert_node_access_reducer.ts new file mode 100644 index 0000000..a485b0b --- /dev/null +++ b/frontend/src/lib/spacetime/module_bindings/upsert_node_access_reducer.ts @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + subjectId: __t.string(), + objectId: __t.string(), + access: __t.string(), + viaEdge: __t.string(), +}; diff --git a/frontend/src/lib/spacetime/stores.svelte.ts b/frontend/src/lib/spacetime/stores.svelte.ts index c1c56fb..4d3f26b 100644 --- a/frontend/src/lib/spacetime/stores.svelte.ts +++ b/frontend/src/lib/spacetime/stores.svelte.ts @@ -13,7 +13,7 @@ * const myEdges = $derived(edgeStore.bySource('user-123')); */ -import type { Node, Edge } from './module_bindings/types'; +import type { Node, Edge, NodeAccess } from './module_bindings/types'; import type { DbConnection, EventContext } from './module_bindings'; // --------------------------------------------------------------------------- @@ -198,6 +198,116 @@ function createEdgeStore() { export const edgeStore = createEdgeStore(); +// --------------------------------------------------------------------------- +// NodeAccess store +// --------------------------------------------------------------------------- + +let _access = $state>(new Map()); +let _accessVersion = $state(0); + +// Secondary index: subject_id → set of object_ids +let _accessBySubject = $state>>(new Map()); + +function createNodeAccessStore() { + return { + /** Get all object_ids this subject has access to. */ + objectsForSubject(subjectId: string): Set { + void _accessVersion; + return _accessBySubject.get(subjectId) ?? new Set(); + }, + + /** Check if subject has access to object. */ + hasAccess(subjectId: string, objectId: string): boolean { + void _accessVersion; + const key = `${subjectId}:${objectId}`; + return _access.has(key); + }, + + /** Get access level for subject→object, or undefined. */ + getAccess(subjectId: string, objectId: string): string | undefined { + void _accessVersion; + const key = `${subjectId}:${objectId}`; + return _access.get(key)?.access; + }, + + /** Number of access entries. */ + get count(): number { + void _accessVersion; + return _access.size; + }, + + // -- Internal callbacks for SpacetimeDB -- + _onInsert(_ctx: EventContext, row: NodeAccess) { + _access.set(row.id, row); + let set = _accessBySubject.get(row.subjectId); + if (!set) { + set = new Set(); + _accessBySubject.set(row.subjectId, set); + } + set.add(row.objectId); + _accessVersion++; + }, + _onDelete(_ctx: EventContext, row: NodeAccess) { + _access.delete(row.id); + const set = _accessBySubject.get(row.subjectId); + if (set) { + set.delete(row.objectId); + if (set.size === 0) _accessBySubject.delete(row.subjectId); + } + _accessVersion++; + }, + _onUpdate(_ctx: EventContext, oldRow: NodeAccess, newRow: NodeAccess) { + _access.set(newRow.id, newRow); + // subject/object don't change (id is composite key), only access level + _accessVersion++; + }, + _clear() { + _access = new Map(); + _accessBySubject = new Map(); + _accessVersion++; + }, + }; +} + +export const nodeAccessStore = createNodeAccessStore(); + +// --------------------------------------------------------------------------- +// Visibility filter +// --------------------------------------------------------------------------- + +/** + * Determines if a node is visible to the given user based on: + * 1. User created the node (created_by) + * 2. User has explicit access via node_access + * 3. Node visibility is 'readable' or 'open' (public to all) + * 4. Node visibility is 'discoverable' (visible but content limited) + * + * Returns: 'full' | 'discoverable' | 'hidden' + */ +export function nodeVisibility( + node: Node, + userId: string | undefined, +): 'full' | 'discoverable' | 'hidden' { + if (!userId) { + // Anonymous: only public nodes + if (node.visibility === 'readable' || node.visibility === 'open') return 'full'; + if (node.visibility === 'discoverable') return 'discoverable'; + return 'hidden'; + } + + // Own node + if (node.createdBy === userId) return 'full'; + + // Explicit access via node_access + if (nodeAccessStore.hasAccess(userId, node.id)) return 'full'; + + // Public visibility + if (node.visibility === 'readable' || node.visibility === 'open') return 'full'; + if (node.visibility === 'discoverable') return 'discoverable'; + + return 'hidden'; +} + // --------------------------------------------------------------------------- // Bind stores to a DbConnection // --------------------------------------------------------------------------- @@ -210,6 +320,7 @@ export function bindStores(conn: DbConnection) { // Clear any stale data nodeStore._clear(); edgeStore._clear(); + nodeAccessStore._clear(); // Register callbacks conn.db.node.onInsert(nodeStore._onInsert); @@ -219,4 +330,8 @@ export function bindStores(conn: DbConnection) { conn.db.edge.onInsert(edgeStore._onInsert); conn.db.edge.onDelete(edgeStore._onDelete); conn.db.edge.onUpdate(edgeStore._onUpdate); + + conn.db.node_access.onInsert(nodeAccessStore._onInsert); + conn.db.node_access.onDelete(nodeAccessStore._onDelete); + conn.db.node_access.onUpdate(nodeAccessStore._onUpdate); } diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index c49e655..37a9b38 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -1,7 +1,7 @@