From 36c427d17ee479d7c9324a299e32ab5afdd8ec54 Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 23 Apr 2026 21:25:29 +0200 Subject: [PATCH] fix(scope): align scope filter with guest-mode write hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getInScopeSpaceIds() used getCurrentUserId() (null for guests), so guest-created rows stamped `_personal:guest` by the write hook became invisible — empty scene, "App hinzufügen" silently no-op'd because activeSceneIdState resolved to null. Switch to getEffectiveUserId() so the read filter always matches what the hook stamps. Four regression tests cover guest-only, signed-in-no-space, non-personal active space, and personal-sentinel- is-active collapsing to a single id. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../apps/web/src/lib/data/scope/scope.test.ts | 64 ++++++++++++++++++- .../apps/web/src/lib/data/scope/scoped-db.ts | 27 +++++--- 2 files changed, 80 insertions(+), 11 deletions(-) diff --git a/apps/mana/apps/web/src/lib/data/scope/scope.test.ts b/apps/mana/apps/web/src/lib/data/scope/scope.test.ts index 7e5d594ba..98586792e 100644 --- a/apps/mana/apps/web/src/lib/data/scope/scope.test.ts +++ b/apps/mana/apps/web/src/lib/data/scope/scope.test.ts @@ -7,8 +7,14 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { applyVisibility, isVisibleToCurrentUser } from './visibility'; import { personalSpaceSentinel } from './bootstrap'; -import { assertModuleAllowed, ModuleNotInSpaceError, ScopeNotReadyError } from './scoped-db'; +import { + assertModuleAllowed, + getInScopeSpaceIds, + ModuleNotInSpaceError, + ScopeNotReadyError, +} from './scoped-db'; import { setActiveSpace } from './active-space.svelte'; +import { setCurrentUserId } from '../current-user'; import * as currentUser from '../current-user'; describe('personalSpaceSentinel', () => { @@ -21,6 +27,62 @@ describe('personalSpaceSentinel', () => { }); }); +describe('getInScopeSpaceIds', () => { + // getInScopeSpaceIds reads `getEffectiveUserId()`, which closes over + // the module-level `currentUserId` inside current-user.ts. Spying on + // the exported `getCurrentUserId` doesn't intercept that closure — + // we need the real setter to change the underlying state. + beforeEach(() => { + setActiveSpace(null); + setCurrentUserId(null); + }); + + afterEach(() => { + setActiveSpace(null); + setCurrentUserId(null); + }); + + it('returns the guest sentinel when no one is signed in (guest-mode)', () => { + // Regression guard: before the fix, this returned [] which + // invisibly hid every guest-created row even though the write + // path stamped them with `_personal:guest`. Result: empty scene, + // "App hinzufügen" silently no-op'd because activeSceneIdState + // resolved to null. + expect(getInScopeSpaceIds()).toEqual(['_personal:guest']); + }); + + it("returns the user's sentinel when signed in without active space", () => { + setCurrentUserId('user-abc'); + expect(getInScopeSpaceIds()).toEqual(['_personal:user-abc']); + }); + + it('returns [active, sentinel] when a non-personal Space is active', () => { + setCurrentUserId('user-abc'); + setActiveSpace({ + id: 'space-xyz', + slug: 'family', + name: 'Family', + type: 'family', + tier: 'public', + role: 'owner', + }); + expect(getInScopeSpaceIds().sort()).toEqual(['_personal:user-abc', 'space-xyz'].sort()); + }); + + it('collapses to [active] when active IS the personal sentinel', () => { + setCurrentUserId('user-abc'); + setActiveSpace({ + id: '_personal:user-abc', + slug: 'personal', + name: 'Personal', + type: 'personal', + tier: 'public', + role: 'owner', + }); + expect(getInScopeSpaceIds()).toEqual(['_personal:user-abc']); + }); +}); + describe('visibility', () => { beforeEach(() => { vi.spyOn(currentUser, 'getCurrentUserId').mockReturnValue('me'); diff --git a/apps/mana/apps/web/src/lib/data/scope/scoped-db.ts b/apps/mana/apps/web/src/lib/data/scope/scoped-db.ts index 87e7e13e9..5ced28151 100644 --- a/apps/mana/apps/web/src/lib/data/scope/scoped-db.ts +++ b/apps/mana/apps/web/src/lib/data/scope/scoped-db.ts @@ -19,7 +19,7 @@ import type { Collection, Table } from 'dexie'; import { db } from '../database'; -import { getCurrentUserId } from '../current-user'; +import { getEffectiveUserId } from '../current-user'; import { getActiveSpaceId } from './active-space.svelte'; import { personalSpaceSentinel } from './bootstrap'; import { isModuleAllowedInSpace, type SpaceModuleId, type SpaceType } from '@mana/shared-types'; @@ -43,20 +43,27 @@ export class ModuleNotInSpaceError extends Error { * Return the set of spaceId values a record must match to be considered * "in scope" right now. * - * Lenient during boot: if the active space hasn't loaded yet, falls back - * to the user's personal sentinel so records stamped by the v28 - * migration still render. Returns `[]` only when truly unauthenticated - * — that yields an empty-filter (matches nothing), which is the safest - * thing a wrapper can do pre-login. + * Always uses `getEffectiveUserId()` (which returns the GUEST_USER_ID + * sentinel `'guest'` when no user is signed in) so the filter we apply + * here matches what the creating-hook in `database.ts` stamps on new + * rows. The hook always stamps `_personal:${effectiveUserId}` — if the + * filter used `getCurrentUserId()` (which is null for guests), guest- + * mode data would be written but never readable until sign-in, which + * breaks the first-run "try the app without an account" flow. + * + * Authenticated path returns either `[active]` (Space-loaded) or + * `[active, sentinel]` when both differ (covers the bootstrap window + * where a row was stamped with the personal sentinel before + * `loadActiveSpace` resolved the real organisation id). */ export function getInScopeSpaceIds(): string[] { const active = getActiveSpaceId(); - const userId = getCurrentUserId(); - const sentinel = userId ? personalSpaceSentinel(userId) : null; + const effectiveUserId = getEffectiveUserId(); + const sentinel = personalSpaceSentinel(effectiveUserId); if (active) { - return sentinel && sentinel !== active ? [active, sentinel] : [active]; + return sentinel !== active ? [active, sentinel] : [active]; } - return sentinel ? [sentinel] : []; + return [sentinel]; } /**