From 38d35247cdd37a93b75e2afa1b84fbc1515d8384 Mon Sep 17 00:00:00 2001 From: Till JS Date: Mon, 20 Apr 2026 20:46:53 +0200 Subject: [PATCH] feat(spaces): end-to-end shared-space sync (membership lookup + plaintext) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the gap between "invite flow UI exists" and "two users in the same space actually see each other's data". Three pieces land together because they're meaningless without each other. mana-auth — new internal endpoint: GET /api/v1/internal/users/:userId/memberships Returns [{organizationId, role}, ...] for the user. mana-sync uses this to populate the multi-member RLS session config. mana-sync — membership lookup: new internal/memberships package with an HTTP client + 5 min per-user cache, fail-open (empty list = pre-Spaces behavior). Config gets MANA_AUTH_URL (default http://localhost:3001). Handler.NewHandler takes the Lookup. Every Push/Pull/Stream call now passes spaceIDsFor(userID) to Store methods. GetChangesSince + GetAllChangesSince extend their WHERE clause: WHERE (user_id = $1 OR space_id = ANY($memberSpaces)) so co-members see each other's rows, not just the author. apps/web — encryption skip for shared-space records: encryptRecord now checks record.spaceId: - `_personal:` sentinel OR no active shared space → encrypt with user master key (E2E as today). - Active space resolves to non-personal type AND spaceId matches that space → skip encryption; write lands plaintext. decryptRecord is unchanged because its per-field isEncrypted() guard already passes plaintext through. Phase-1 compromise: shared-space data is protected by server RLS only, not E2E. Phase 2 adds per-Space shared keys with per-member wrap — tracked in docs/plans/spaces-foundation.md. Plus docs/plans/shared-space-smoketest.md: step-by-step Zwei-User-Test mit erwarteten Ergebnissen und Debugging-Hinweisen bei Problemen. Build + go test + web check all green. Plan: docs/plans/spaces-foundation.md Co-Authored-By: Claude Opus 4.7 (1M context) --- .../web/src/lib/data/crypto/record-helpers.ts | 43 ++++++ docs/plans/shared-space-smoketest.md | 124 +++++++++++++++++ services/mana-auth/src/index.ts | 23 ++++ services/mana-sync/cmd/server/main.go | 9 +- services/mana-sync/internal/config/config.go | 2 + .../mana-sync/internal/memberships/lookup.go | 129 ++++++++++++++++++ services/mana-sync/internal/store/postgres.go | 20 +-- services/mana-sync/internal/sync/handler.go | 33 +++-- 8 files changed, 365 insertions(+), 18 deletions(-) create mode 100644 docs/plans/shared-space-smoketest.md create mode 100644 services/mana-sync/internal/memberships/lookup.go diff --git a/apps/mana/apps/web/src/lib/data/crypto/record-helpers.ts b/apps/mana/apps/web/src/lib/data/crypto/record-helpers.ts index 86402796c..0aa4db29a 100644 --- a/apps/mana/apps/web/src/lib/data/crypto/record-helpers.ts +++ b/apps/mana/apps/web/src/lib/data/crypto/record-helpers.ts @@ -37,6 +37,42 @@ import { wrapValue, unwrapValue, isEncrypted } from './aes'; import { getActiveKey, isVaultUnlocked, waitForActiveKey } from './key-provider'; import { getEncryptedFields } from './registry'; import { getCurrentUserId } from '../current-user'; +import { getActiveSpace } from '../scope/active-space.svelte'; + +/** + * Phase-1 Spaces decision: personal-Space data is encrypted with the + * user's master key (E2E as today); shared-Space data (brand/club/family + * /team/practice) is plaintext at rest and protected by server RLS only. + * + * Deciding at write time: + * - spaceId is the `_personal:` sentinel → personal, encrypt. + * Happens during the bootstrap window before reconcileSentinels() has + * rewritten the placeholder to the real personal-space id. + * - Active space is resolved and type='personal' → encrypt. + * - Active space is any other type → skip. + * - Record has no spaceId at all (legacy rows, guest mode) → encrypt + * (safer default; personal-space is what the app defaults to). + * + * A shared-Space encryption-with-shared-key scheme will replace this + * later; see docs/plans/spaces-foundation.md §"Shared-space encryption". + */ +function isPersonalScope(record: Record): boolean { + const spaceId = record.spaceId; + if (typeof spaceId === 'string' && spaceId.startsWith('_personal:')) { + return true; + } + const active = getActiveSpace(); + if (active && active.type !== 'personal') { + // Only skip when we can confidently say the write belongs to a + // shared space. If the record's spaceId matches some other space's + // id, the check would need a lookup — for now the active-space + // type is the authoritative signal at write time. + if (typeof spaceId === 'string' && spaceId === active.id) { + return false; + } + } + return true; +} /** Thrown by encryptRecord when no key is available. Module stores * catch this to surface "vault locked" UI. */ @@ -132,6 +168,13 @@ export async function encryptRecord(tableName: string, record: if (!fields) return record; const view = record as unknown as Record; + // Phase-1 Spaces: shared-Space records skip encryption so co-members + // can read them (they don't have the author's master key). Personal + // data stays E2E. + if (!isPersonalScope(view)) { + return record; + } + if (import.meta.env.DEV) devCheckRegistryShape(tableName, view, fields); // Build the work list first so we don't half-encrypt a record on diff --git a/docs/plans/shared-space-smoketest.md b/docs/plans/shared-space-smoketest.md new file mode 100644 index 000000000..14a5251e1 --- /dev/null +++ b/docs/plans/shared-space-smoketest.md @@ -0,0 +1,124 @@ +# Shared-Space Smoketest + +Schritt-für-Schritt-Anleitung, um das Shared-Space-Ende zu validieren. +Zwei Nutzer, ein gemeinsamer Space, echtes Sync + Einladungsflow. + +## Vorbereitung + +Lokaler Stack läuft (`pnpm docker:up && pnpm run mana:dev`). Vor dem Test +einmal sicherstellen: + +1. **DB-Migrationen aktuell:** + ```bash + cd services/mana-auth && bun run db:push + ``` + Holt das `spaces`-Schema (v004) mit `credentials` + `module_permissions`. + mana-sync's eigene Migration (inkl. multi-member RLS) läuft beim Service-Start automatisch. + +2. **Dev-User vorhanden:** + ```bash + pnpm setup:dev-user + ``` + Legt drei Accounts an (`tills95@gmail.com`, `tilljkb@gmail.com`, + `rajiehq@gmail.com`), alle mit Passwort `Aa-123456789` und Founder-Tier. + Durch den Signup-Hook bekommt jeder automatisch seinen Personal-Space. + +3. **SMTP-Stub aktiv (für Invite-Mails):** + Stalwart SMTP läuft im `docker-compose.macmini.yml`-Stack. Lokal kann + man den Invite-Link auch direkt aus den Logs von mana-auth ziehen, + falls die Mail nicht ankommt. + +## Szenario: Familie + +**Zwei getrennte Browser-Profile** (oder ein normales + ein Inkognito-Fenster). +Ohne Profil-Trennung teilen sich die Sessions die Cookies, und der Test +ist wertlos. + +### User A (Besitzer) + +1. Einloggen als `tills95@gmail.com` +2. Oben rechts im Space-Switcher "+ Neuer Space" klicken +3. Typ **Familie**, Name "Schmidt Family", Slug automatisch +4. Erstellen → Reload in den neuen Space, Switcher zeigt "Schmidt Family" + grünes Family-Badge +5. Im Switcher → "Mitglieder verwalten …" +6. Einladen-Formular: `tilljkb@gmail.com` als `Mitglied` → Senden +7. Erfolgsmeldung "Einladung an … gesendet", Invite erscheint in "Offene Einladungen" + +### User B (Eingeladener) + +1. E-Mail öffnen, Einladungslink anklicken (`/accept-invitation?id=…`) +2. Falls nicht eingeloggt: "Einloggen & annehmen" — bringt zu Login, + nach erfolgreicher Authentifizierung automatisch zurück auf Accept-Seite +3. Space-Vorschau zeigt: "tills95@gmail.com lädt dich in **Schmidt Family** ein", + Typ-Badge "Familie", Rolle "Mitglied" +4. Annehmen klicken → Redirect auf `/`, Space-Switcher oben rechts zeigt jetzt "Schmidt Family" + +### Gemeinsamer Test (wichtig!) + +5. **User A** legt im Familien-Space ein Event für morgen an (Kalender-Modul) +6. **User B** öffnet den Kalender — das Event erscheint nach ≤1 s +7. **User B** legt ein Rezept an +8. **User A** sieht das Rezept nach ≤1 s + +### Isolation-Check (wichtig!) + +9. **User A** wechselt auf den Personal-Space (Switcher) +10. User A trägt einen privaten Mood-Eintrag ein +11. **User B** wechselt auf den Familien-Space und öffnet den Kalender-Modul + — User A's Personal-Daten dürfen **nicht** sichtbar sein. Mood-Modul + ist im Familien-Space eh nicht freigeschaltet (SPACE_MODULE_ALLOWLIST). + +### Rücktritt-Test + +12. **User A** öffnet "Mitglieder verwalten …" und entfernt User B +13. **User B** lädt die Seite neu — Familien-Space ist verschwunden, + aktiver Space fällt zurück auf Personal + +## Was dabei geprüft wird + +| Feature | Erwartung | +|---|---| +| Invite-Mail versandt | Stalwart-Log zeigt Mail, oder E-Mail-Inbox hat Link | +| Better-Auth `accept-invitation` | `auth.invitations.status` → `accepted` | +| Member-Datensatz | `auth.members` hat neuen Row für User B × org | +| `activeOrganizationId` gesetzt | Session-Cookie zeigt den neuen Space | +| Scope-Filter greift | User B sieht keine Personal-Daten von User A | +| Multi-Member-RLS greift | User B sieht neue Events/Rezepte von User A | +| Membership-Lookup in mana-sync | Keine 404s im mana-sync-Log beim Zugriff | +| Encryption-Skip | Records im Familien-Space werden plaintext synced (prüfen via DevTools → IndexedDB) | +| Rollen-Enforcement | User B kann **nicht** weitere einladen (Rolle=Mitglied, canManage=false) | + +## Bekannte Phase-1-Einschränkungen + +- **Daten im Shared-Space sind unverschlüsselt** (nur RLS-geschützt) — im + RFC `docs/plans/spaces-foundation.md` als bewusster Phase-1-Kompromiss + dokumentiert. Phase 2 addiert einen pro-Space-Schlüssel mit Per-Member-Wrap. +- **Subscription-Fan-Out läuft noch per-User** — User A's Write triggert + Mana-Sync's Pull erst nach seinem eigenen Tick; User B sieht den + Eintrag nach bis zu 1 s, nicht instant. Sobald Fan-Out steht, ist das + Sub-200ms. +- **Role-Änderungen brauchen Cache-Invalidierung**: nach Role-Update + dauert die mana-sync-Membership-Cache bis zu 5 min. Für Tests nach + dem Leave einfach den Service neu starten. + +## Was tun wenn's nicht klappt + +- **E-Mail kommt nicht an**: in mana-auth-Logs nach `accept-invitation?id=` + suchen — Better Auth loggt den Link bei `sendInvitationEmail`. +- **User B sieht Space nicht nach Accept**: Browser-DevTools → Cookies → + `mana.session_data` auf die neue Session-ID prüfen, dann `loadActiveSpace` + in der Konsole forcen: `await (await import('/src/lib/data/scope/index.ts')).loadActiveSpace({ force: true })` +- **Rezepte erscheinen bei A nicht**: mana-sync-Logs auf `[memberships]` + oder Policy-Denials checken. `app.current_user_space_ids` sollte die + Schmidt-Family-Org-ID enthalten. +- **Kein Access zu Mitglieder-Seite**: Personal-Space kann per Design + keine Mitglieder haben — vorher auf den Familien-Space wechseln. + +## Nächste Schritte nach Smoketest + +Wenn alle 9+ Checks grün sind: Foundation ist validated. +Follow-ups (nicht blockierend): +- Echtes WebSocket-Fan-Out an Space-Mitglieder +- Shared-Space-Encryption mit per-Member-Key-Wrap +- Member-Activity-Log (wer hat wann was geändert) +- Rollen-spezifische Permissions (trainer darf kein club-finance) diff --git a/services/mana-auth/src/index.ts b/services/mana-auth/src/index.ts index 156499b74..dfad9cc86 100644 --- a/services/mana-auth/src/index.ts +++ b/services/mana-auth/src/index.ts @@ -136,6 +136,29 @@ app.get('/api/v1/internal/org/:orgId/member/:userId', async (c) => { return c.json({ isMember: !!member, role: member?.role || '' }); }); +/** + * List every Space (organization) the given user is a member of. Used by + * mana-sync to pass the current user's space-membership list into the + * `app.current_user_space_ids` session setting so the multi-member RLS + * policy can let space co-members read each other's records. + * + * Returns a flat array of organization ids — mana-sync doesn't care + * about names/roles here, only the set. Cached 5 min client-side. + */ +app.get('/api/v1/internal/users/:userId/memberships', async (c) => { + const { userId } = c.req.param(); + const { members } = await import('./db/schema/organizations'); + const { eq } = await import('drizzle-orm'); + const rows = await db + .select({ organizationId: members.organizationId, role: members.role }) + .from(members) + .where(eq(members.userId, userId)); + return c.json({ + userId, + memberships: rows.map((r) => ({ organizationId: r.organizationId, role: r.role })), + }); +}); + // ─── Login Page (OIDC) ───────────────────────────────────── app.get('/login', (c) => { diff --git a/services/mana-sync/cmd/server/main.go b/services/mana-sync/cmd/server/main.go index 99da65a67..c39a65376 100644 --- a/services/mana-sync/cmd/server/main.go +++ b/services/mana-sync/cmd/server/main.go @@ -16,6 +16,7 @@ import ( "github.com/mana/mana-sync/internal/backup" "github.com/mana/mana-sync/internal/billing" "github.com/mana/mana-sync/internal/config" + "github.com/mana/mana-sync/internal/memberships" "github.com/mana/mana-sync/internal/store" syncHandler "github.com/mana/mana-sync/internal/sync" "github.com/mana/mana-sync/internal/ws" @@ -55,8 +56,14 @@ func main() { billingChecker := billing.NewChecker(cfg.ManaCreditsURL, cfg.ServiceKey) billingMiddleware := billingChecker.Middleware(validator) + // Initialize Space-membership lookup against mana-auth. The handler + // passes the caller's membership list into every sync query so the + // multi-member RLS policy lets co-members of a shared Space see each + // other's records. + membershipLookup := memberships.New(cfg.ManaAuthURL, cfg.ServiceKey) + // Initialize sync handler - handler := syncHandler.NewHandler(db, validator, hub) + handler := syncHandler.NewHandler(db, validator, hub, membershipLookup) // Set up routes mux := http.NewServeMux() diff --git a/services/mana-sync/internal/config/config.go b/services/mana-sync/internal/config/config.go index 5b1badf81..1308a8e17 100644 --- a/services/mana-sync/internal/config/config.go +++ b/services/mana-sync/internal/config/config.go @@ -10,6 +10,7 @@ type Config struct { Port int DatabaseURL string JWKSUrl string // mana-auth JWKS endpoint for JWT validation + ManaAuthURL string // mana-auth base URL for internal APIs (Space memberships) CORSOrigins string ManaCreditsURL string // mana-credits service URL for billing checks ServiceKey string // Service-to-service auth key @@ -23,6 +24,7 @@ func Load() *Config { Port: port, DatabaseURL: getEnv("DATABASE_URL", "postgresql://mana:devpassword@localhost:5432/mana_sync"), JWKSUrl: getEnv("JWKS_URL", "http://localhost:3001/api/auth/jwks"), + ManaAuthURL: getEnv("MANA_AUTH_URL", "http://localhost:3001"), CORSOrigins: getEnv("CORS_ORIGINS", "http://localhost:5173,http://localhost:5188"), ManaCreditsURL: getEnv("MANA_CREDITS_URL", "http://localhost:3061"), ServiceKey: getEnv("MANA_SERVICE_KEY", "dev-service-key"), diff --git a/services/mana-sync/internal/memberships/lookup.go b/services/mana-sync/internal/memberships/lookup.go new file mode 100644 index 000000000..ab049fd2e --- /dev/null +++ b/services/mana-sync/internal/memberships/lookup.go @@ -0,0 +1,129 @@ +// Package memberships fetches a user's Space membership list from +// mana-auth and caches it in-memory. +// +// The list is passed into every sync transaction's +// `app.current_user_space_ids` session setting so the multi-member RLS +// policy on sync_changes can let co-members of a shared Space read each +// other's records. See docs/plans/spaces-foundation.md. +package memberships + +import ( + "encoding/json" + "fmt" + "log/slog" + "net/http" + "sync" + "time" +) + +// Membership is the subset of the /internal/users/:id/memberships +// response shape that we care about — mana-sync doesn't need names or +// metadata, just the set of org IDs and the caller's role in each. +type Membership struct { + OrganizationID string `json:"organizationId"` + Role string `json:"role"` +} + +type response struct { + UserID string `json:"userId"` + Memberships []Membership `json:"memberships"` +} + +type cached struct { + memberships []Membership + fetchedAt time.Time +} + +// Lookup queries mana-auth for the Space memberships of a user and +// caches the result per-user. +type Lookup struct { + authURL string + serviceKey string + cacheTTL time.Duration + client *http.Client + + mu sync.RWMutex + cache map[string]cached +} + +// New creates a Lookup bound to a mana-auth base URL. +func New(authURL, serviceKey string) *Lookup { + return &Lookup{ + authURL: authURL, + serviceKey: serviceKey, + cacheTTL: 5 * time.Minute, + client: &http.Client{Timeout: 5 * time.Second}, + cache: make(map[string]cached), + } +} + +// For returns the list of organization IDs the user is a member of. +// Fail-open: on any transport error or non-2xx response, returns an +// empty slice. An empty list is safe — the user_id RLS policy still +// gates visibility to the caller's own rows, so the worst case is that +// a shared-space record temporarily looks invisible to a co-member +// until the lookup succeeds on the next request. +func (l *Lookup) For(userID string) []string { + if userID == "" { + return nil + } + + l.mu.RLock() + entry, ok := l.cache[userID] + l.mu.RUnlock() + if ok && time.Since(entry.fetchedAt) < l.cacheTTL { + return toIDs(entry.memberships) + } + + memberships := l.fetch(userID) + if memberships != nil { + l.mu.Lock() + l.cache[userID] = cached{memberships: memberships, fetchedAt: time.Now()} + l.mu.Unlock() + } + return toIDs(memberships) +} + +// Invalidate drops the cached entry for a user. Called when the user's +// membership set demonstrably changed (invite accepted, member removed +// via HTTP handler). Not strictly required — the cache expires on its +// own — but makes local dogfooding feel responsive. +func (l *Lookup) Invalidate(userID string) { + l.mu.Lock() + delete(l.cache, userID) + l.mu.Unlock() +} + +func (l *Lookup) fetch(userID string) []Membership { + url := fmt.Sprintf("%s/api/v1/internal/users/%s/memberships", l.authURL, userID) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + slog.Warn("[memberships] build request failed", "error", err, "userId", userID) + return nil + } + req.Header.Set("x-service-key", l.serviceKey) + resp, err := l.client.Do(req) + if err != nil { + slog.Warn("[memberships] fetch failed", "error", err, "userId", userID) + return nil + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + slog.Warn("[memberships] non-200", "status", resp.StatusCode, "userId", userID) + return nil + } + var body response + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + slog.Warn("[memberships] decode failed", "error", err, "userId", userID) + return nil + } + return body.Memberships +} + +func toIDs(ms []Membership) []string { + out := make([]string, 0, len(ms)) + for _, m := range ms { + out = append(out, m.OrganizationID) + } + return out +} diff --git a/services/mana-sync/internal/store/postgres.go b/services/mana-sync/internal/store/postgres.go index 691e4c0de..89e0bb66c 100644 --- a/services/mana-sync/internal/store/postgres.go +++ b/services/mana-sync/internal/store/postgres.go @@ -259,23 +259,25 @@ func (s *Store) RecordChange(ctx context.Context, appID, tableName, recordID, us // GetChangesSince returns changes for a user+app+table since a given timestamp, // excluding changes from the requesting client (to avoid echo). // The limit parameter controls maximum rows returned (caller should pass limit+1 to detect hasMore). -func (s *Store) GetChangesSince(ctx context.Context, userID, appID, tableName, since, excludeClientID string, limit int) ([]ChangeRow, error) { +// spaceIDs is the caller's Space membership list — rows whose space_id matches any of these +// are also returned (in addition to rows the caller authored). +func (s *Store) GetChangesSince(ctx context.Context, userID, appID, tableName, since, excludeClientID string, limit int, spaceIDs []string) ([]ChangeRow, error) { sinceTime, err := time.Parse(time.RFC3339Nano, since) if err != nil { sinceTime = time.Unix(0, 0) } var changes []ChangeRow - err = s.withUser(ctx, userID, func(tx pgx.Tx) error { + err = s.withUserAndMemberships(ctx, userID, spaceIDs, func(tx pgx.Tx) error { query := ` SELECT id, table_name, record_id, op, data, field_timestamps, client_id, created_at, schema_version, space_id, actor FROM sync_changes - WHERE user_id = $1 AND app_id = $2 AND table_name = $3 + WHERE (user_id = $1 OR space_id = ANY($7)) AND app_id = $2 AND table_name = $3 AND created_at > $4 AND client_id != $5 ORDER BY created_at ASC LIMIT $6 ` - rows, err := tx.Query(ctx, query, userID, appID, tableName, sinceTime, excludeClientID, limit) + rows, err := tx.Query(ctx, query, userID, appID, tableName, sinceTime, excludeClientID, limit, spaceIDs) if err != nil { return err } @@ -315,23 +317,25 @@ func (s *Store) GetChangesSince(ctx context.Context, userID, appID, tableName, s } // GetAllChangesSince returns changes across all tables for a user+app. -func (s *Store) GetAllChangesSince(ctx context.Context, userID, appID, since, excludeClientID string) ([]ChangeRow, error) { +// spaceIDs is the caller's Space membership list — rows whose space_id matches any of these +// are also returned (in addition to rows the caller authored). +func (s *Store) GetAllChangesSince(ctx context.Context, userID, appID, since, excludeClientID string, spaceIDs []string) ([]ChangeRow, error) { sinceTime, err := time.Parse(time.RFC3339Nano, since) if err != nil { sinceTime = time.Unix(0, 0) } var changes []ChangeRow - err = s.withUser(ctx, userID, func(tx pgx.Tx) error { + err = s.withUserAndMemberships(ctx, userID, spaceIDs, func(tx pgx.Tx) error { query := ` SELECT id, table_name, record_id, op, data, field_timestamps, client_id, created_at, schema_version, space_id, actor FROM sync_changes - WHERE user_id = $1 AND app_id = $2 + WHERE (user_id = $1 OR space_id = ANY($5)) AND app_id = $2 AND created_at > $3 AND client_id != $4 ORDER BY created_at ASC LIMIT 5000 ` - rows, err := tx.Query(ctx, query, userID, appID, sinceTime, excludeClientID) + rows, err := tx.Query(ctx, query, userID, appID, sinceTime, excludeClientID, spaceIDs) if err != nil { return err } diff --git a/services/mana-sync/internal/sync/handler.go b/services/mana-sync/internal/sync/handler.go index 8d62b8e52..c674cc002 100644 --- a/services/mana-sync/internal/sync/handler.go +++ b/services/mana-sync/internal/sync/handler.go @@ -9,20 +9,34 @@ import ( "time" "github.com/mana/mana-sync/internal/auth" + "github.com/mana/mana-sync/internal/memberships" "github.com/mana/mana-sync/internal/store" "github.com/mana/mana-sync/internal/ws" ) // Handler handles sync HTTP endpoints. type Handler struct { - store *store.Store - validator *auth.Validator - hub *ws.Hub + store *store.Store + validator *auth.Validator + hub *ws.Hub + memberships *memberships.Lookup } // NewHandler creates a new sync handler. -func NewHandler(s *store.Store, v *auth.Validator, h *ws.Hub) *Handler { - return &Handler{store: s, validator: v, hub: h} +// memberships may be nil — if so, the handler treats every user as +// having no shared-space memberships (same as pre-Spaces behavior). +func NewHandler(s *store.Store, v *auth.Validator, h *ws.Hub, m *memberships.Lookup) *Handler { + return &Handler{store: s, validator: v, hub: h, memberships: m} +} + +// spaceIDsFor returns the Space membership list for the caller, or an +// empty slice if no lookup is configured. Used to populate the session +// config the multi-member RLS policy reads. +func (h *Handler) spaceIDsFor(userID string) []string { + if h.memberships == nil { + return nil + } + return h.memberships.For(userID) } // maxBodySize is the maximum allowed request body (10 MB). @@ -210,7 +224,7 @@ func (h *Handler) HandleSync(w http.ResponseWriter, r *http.Request) { } // Get server changes since client's last sync (excluding client's own changes) - serverChanges, err := h.store.GetAllChangesSince(ctx, userID, appID, changeset.Since, clientID) + serverChanges, err := h.store.GetAllChangesSince(ctx, userID, appID, changeset.Since, clientID, h.spaceIDsFor(userID)) if err != nil { slog.Error("failed to get server changes", "error", err) http.Error(w, "internal error", http.StatusInternalServerError) @@ -275,7 +289,7 @@ func (h *Handler) HandlePull(w http.ResponseWriter, r *http.Request) { ctx := r.Context() const batchLimit = 1000 - serverChanges, err := h.store.GetChangesSince(ctx, userID, appID, collection, since, clientID, batchLimit+1) + serverChanges, err := h.store.GetChangesSince(ctx, userID, appID, collection, since, clientID, batchLimit+1, h.spaceIDsFor(userID)) if err != nil { slog.Error("failed to get changes", "error", err) http.Error(w, "internal error", http.StatusInternalServerError) @@ -374,8 +388,9 @@ func (h *Handler) HandleStream(w http.ResponseWriter, r *http.Request) { } // Initial sync: send pending changes since cursor for each collection + memberSpaceIDs := h.spaceIDsFor(userID) for _, coll := range collections { - changes, err := h.store.GetChangesSince(ctx, userID, appID, coll, since, clientID, batchLimit+1) + changes, err := h.store.GetChangesSince(ctx, userID, appID, coll, since, clientID, batchLimit+1, memberSpaceIDs) if err != nil { slog.Error("SSE initial pull failed", "error", err, "collection", coll) cursors[coll] = now // Default to now on error @@ -416,7 +431,7 @@ func (h *Handler) HandleStream(w http.ResponseWriter, r *http.Request) { if cursor == "" { cursor = since } - changes, err := h.store.GetChangesSince(ctx, userID, appID, table, cursor, clientID, batchLimit+1) + changes, err := h.store.GetChangesSince(ctx, userID, appID, table, cursor, clientID, batchLimit+1, memberSpaceIDs) if err != nil || len(changes) == 0 { continue }