fix(scope): align scope filter with guest-mode write hook

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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-23 21:25:29 +02:00
parent e0820331b0
commit 36c427d17e
2 changed files with 80 additions and 11 deletions

View file

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

View file

@ -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];
}
/**