feat(spaces): end-to-end shared-space sync (membership lookup + plaintext)

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:<userId>` 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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-20 20:46:53 +02:00
parent da373491b8
commit 38d35247cd
8 changed files with 365 additions and 18 deletions

View file

@ -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:<userId>` 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<string, unknown>): 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<T extends object>(tableName: string, record:
if (!fields) return record;
const view = record as unknown as Record<string, unknown>;
// 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