Frontend: visibility-filtrering med nodeAccessStore

Visibility-filtrering (oppgave 4.3, del 2/2):
- Regenerert STDB TypeScript-bindinger med node_access-tabell
- nodeAccessStore: reaktiv Svelte 5 store for tilgangsmatrisen
- nodeVisibility()-funksjon: returnerer 'full'/'discoverable'/'hidden'
  basert på created_by, node_access, og visibility-enum
- Mottaksflaten filtrerer noder: hidden-noder skjules,
  discoverable viser kun tittel, readable/open vises fullt
- Abonnerer på node_access-tabell via STDB WebSocket
- Debug-info viser antall access-rader

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-17 15:15:22 +01:00
parent 8fa2849f0c
commit 023b598436
11 changed files with 266 additions and 14 deletions

View file

@ -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) => {

View file

@ -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';

View file

@ -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(),
};

View file

@ -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(),
};

View file

@ -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. */

View file

@ -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"),
});

View file

@ -34,3 +34,12 @@ export const Node = __t.object("Node", {
});
export type Node = __Infer<typeof Node>;
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<typeof NodeAccess>;

View file

@ -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<typeof ClearAllReducer>;
export type CreateEdgeParams = __Infer<typeof CreateEdgeReducer>;
export type CreateNodeParams = __Infer<typeof CreateNodeReducer>;
export type DeleteEdgeParams = __Infer<typeof DeleteEdgeReducer>;
export type DeleteNodeParams = __Infer<typeof DeleteNodeReducer>;
export type DeleteNodeAccessParams = __Infer<typeof DeleteNodeAccessReducer>;
export type DeleteNodeAccessForSubjectParams = __Infer<typeof DeleteNodeAccessForSubjectReducer>;
export type UpdateEdgeParams = __Infer<typeof UpdateEdgeReducer>;
export type UpdateNodeParams = __Infer<typeof UpdateNodeReducer>;
export type UpsertNodeAccessParams = __Infer<typeof UpsertNodeAccessReducer>;

View file

@ -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(),
};

View file

@ -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<Map<string, NodeAccess>>(new Map());
let _accessVersion = $state(0);
// Secondary index: subject_id → set of object_ids
let _accessBySubject = $state<Map<string, Set<string>>>(new Map());
function createNodeAccessStore() {
return {
/** Get all object_ids this subject has access to. */
objectsForSubject(subjectId: string): Set<string> {
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);
}

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { page } from '$app/stores';
import { signOut } from '@auth/sveltekit/client';
import { connectionState, nodeStore, edgeStore } from '$lib/spacetime';
import { connectionState, nodeStore, edgeStore, nodeAccessStore, nodeVisibility } from '$lib/spacetime';
import type { Node } from '$lib/spacetime';
import NodeEditor from '$lib/components/NodeEditor.svelte';
import { createNode, createEdge } from '$lib/api';
@ -12,32 +12,56 @@
const connected = $derived(connectionState.current === 'connected');
/**
* Find all nodes connected to the user via edges (either direction),
* excluding the user's own node and system edges.
* Find all nodes visible to the user, applying visibility filtering.
*
* A node is visible if:
* - User created it (created_by)
* - User has explicit access (node_access)
* - Node is 'readable' or 'open' (public)
* - Node is 'discoverable' (shown with limited info)
*
* Among visible nodes, the mottak shows:
* - Nodes connected via edges (either direction, non-system)
* - Readable/open/discoverable nodes
*
* Sorted by created_at descending (newest first).
*/
const mottaksnoder = $derived.by(() => {
if (!nodeId || !connected) return [];
// Collect node IDs connected to the user
// Collect node IDs connected to the user via edges
const connectedNodeIds = new Set<string>();
// Edges where user is source (e.g., user --owner--> thing)
for (const edge of edgeStore.bySource(nodeId)) {
if (!edge.system) connectedNodeIds.add(edge.targetId);
}
// Edges where user is target (e.g., thing --mentions--> user)
for (const edge of edgeStore.byTarget(nodeId)) {
if (!edge.system) connectedNodeIds.add(edge.sourceId);
}
// Resolve to nodes, excluding the user's own node
// Also include all nodes user has explicit access to
const accessibleIds = nodeAccessStore.objectsForSubject(nodeId);
for (const id of accessibleIds) {
connectedNodeIds.add(id);
}
// Resolve to nodes, applying visibility filter
const nodes: Node[] = [];
for (const id of connectedNodeIds) {
if (id === nodeId) continue;
const node = nodeStore.get(id);
if (node) nodes.push(node);
if (node && nodeVisibility(node, nodeId) !== 'hidden') {
nodes.push(node);
}
}
// Also add public nodes (readable/open) that aren't connected
for (const node of nodeStore.all) {
if (node.id === nodeId) continue;
if (connectedNodeIds.has(node.id)) continue;
if (node.visibility === 'readable' || node.visibility === 'open') {
nodes.push(node);
}
}
// Sort by created_at descending
@ -154,16 +178,21 @@
{:else}
<ul class="space-y-2">
{#each mottaksnoder as node (node.id)}
{@const vis = nodeVisibility(node, nodeId)}
<li class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<h3 class="font-medium text-gray-900">
{node.title || 'Uten tittel'}
</h3>
{#if node.content}
{#if vis === 'full' && node.content}
<p class="mt-1 text-sm text-gray-500">
{excerpt(node.content)}
</p>
{:else if vis === 'discoverable'}
<p class="mt-1 text-xs italic text-gray-400">
Begrenset tilgang — be om tilgang for å se innholdet
</p>
{/if}
</div>
<div class="flex shrink-0 flex-col items-end gap-1">
@ -189,7 +218,7 @@
<!-- Debug info (small, bottom) -->
{#if connected}
<p class="mt-8 text-xs text-gray-300">
{nodeStore.count} noder · {edgeStore.count} edges
{nodeStore.count} noder · {edgeStore.count} edges · {nodeAccessStore.count} access
{#if nodeId}· node: {nodeId.slice(0, 8)}{/if}
</p>
{/if}