fix(spaces): call Better Auth at its real URL, not through SvelteKit

All the Space-UI fetches were using relative `/api/auth/...` paths,
which hit the SvelteKit dev server (port 5173) — where those routes
don't exist — and 404'd. The web app has no `/api/auth` proxy; every
Better Auth call must go direct to mana-auth (port 3001 in dev).

Root cause parallels how packages/shared-auth/authService already
works: it builds `${authBaseUrl}/api/auth/...` against
window.__PUBLIC_MANA_AUTH_URL__ or the env fallback.

Fix:
- New helper $lib/data/scope/auth-fetch.ts exposes authFetch(path, init)
  that prepends the auth base URL and includes credentials by default.
  Same resolution order as shared-auth's authService (injected global,
  env, localhost:3001 fallback).
- Updated every organization-endpoint caller to use authFetch:
  active-space.svelte.ts (list, get-active-member, set-active)
  SpaceSwitcher (list, set-active)
  SpaceCreateDialog (create, set-active)
  accept-invitation page (get-invitation, accept, reject)
  /spaces/members page (list-members, list-invitations, invite-member,
  cancel-invitation, remove-member)
- active-space now treats Better Auth's 400 as "no active org" too
  (not just 404) so the bootstrap falls through to auto-activation.

Trusted origins already include http://localhost:5173 — no CORS change.

0 errors across 7203 files.

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 21:28:45 +02:00
parent 8e677c9066
commit becba67dad
7 changed files with 71 additions and 47 deletions

View file

@ -11,7 +11,7 @@
import { SPACE_TYPES, SPACE_TYPE_LABELS, SPACE_TYPE_DESCRIPTIONS } from '@mana/shared-branding';
import type { SpaceType } from '@mana/shared-types';
import { loadActiveSpace } from '$lib/data/scope';
import { loadActiveSpace, authFetch } from '$lib/data/scope';
interface Props {
open: boolean;
@ -74,10 +74,8 @@
if (legalEntity.trim()) metadata.legalEntity = legalEntity.trim();
try {
const res = await fetch('/api/auth/organization/create', {
const res = await authFetch('/api/auth/organization/create', {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
name: name.trim(),
slug: derivedSlug || undefined,
@ -90,10 +88,8 @@
}
const created = (await res.json()) as { id: string };
// Activate the new space so the user lands inside it on reload.
await fetch('/api/auth/organization/set-active', {
await authFetch('/api/auth/organization/set-active', {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ organizationId: created.id }),
});
await loadActiveSpace({ force: true });

View file

@ -11,7 +11,7 @@
*/
import { onDestroy } from 'svelte';
import { getActiveSpace, loadActiveSpace, type ActiveSpace } from '$lib/data/scope';
import { getActiveSpace, loadActiveSpace, authFetch, type ActiveSpace } from '$lib/data/scope';
import { SPACE_TYPE_LABELS } from '@mana/shared-branding';
import { isSpaceType, isSpaceTier } from '@mana/shared-types';
import SpaceCreateDialog from './SpaceCreateDialog.svelte';
@ -57,7 +57,7 @@
loadingList = true;
loadError = null;
try {
const res = await fetch('/api/auth/organization/list', { credentials: 'include' });
const res = await authFetch('/api/auth/organization/list');
if (!res.ok) throw new Error(`list failed: ${res.status}`);
const raw = (await res.json()) as Array<{
id: string;
@ -97,10 +97,8 @@
}
switching = true;
try {
const res = await fetch('/api/auth/organization/set-active', {
const res = await authFetch('/api/auth/organization/set-active', {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ organizationId: id }),
});
if (!res.ok) throw new Error(`set-active failed: ${res.status}`);

View file

@ -11,6 +11,7 @@
import type { SpaceType, SpaceTier } from '@mana/shared-types';
import { isSpaceType, isSpaceTier } from '@mana/shared-types';
import { authFetch } from './auth-fetch';
export interface ActiveSpace {
id: string;
@ -129,8 +130,11 @@ export const __endpoints = {
};
async function fetchActiveMember(): Promise<ActiveSpace | null> {
const res = await fetch(__endpoints.active, { credentials: 'include' });
if (res.status === 404) return null; // no active org
const res = await authFetch(__endpoints.active);
// Better Auth returns 400/404 when no organization is active yet —
// treat both as "not active" so the bootstrap can fall through to
// auto-activation.
if (res.status === 404 || res.status === 400) return null;
if (!res.ok) throw new Error(`get-active-member failed: ${res.status}`);
const raw = (await res.json()) as {
role?: string;
@ -141,17 +145,15 @@ async function fetchActiveMember(): Promise<ActiveSpace | null> {
}
async function fetchOrganizations(): Promise<ActiveSpace[]> {
const res = await fetch(__endpoints.list, { credentials: 'include' });
const res = await authFetch(__endpoints.list);
if (!res.ok) throw new Error(`organization/list failed: ${res.status}`);
const raws = (await res.json()) as RawOrg[];
return raws.map((r) => rawToActiveSpace(r, 'owner'));
}
async function setActiveOnServer(organizationId: string): Promise<void> {
const res = await fetch(__endpoints.setActive, {
const res = await authFetch(__endpoints.setActive, {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ organizationId }),
});
if (!res.ok) throw new Error(`organization/set-active failed: ${res.status}`);

View file

@ -0,0 +1,41 @@
/**
* Thin wrapper around fetch that prepends the mana-auth base URL and
* always includes credentials. The web app does NOT proxy /api/auth
* through SvelteKit every Better Auth request goes directly to
* mana-auth at http://localhost:3001 (dev) or https://auth.mana.how
* (prod). Using a relative `/api/auth/...` path 404s against the
* SvelteKit dev server.
*
* Resolution order (same as packages/shared-auth/authService):
* 1. window.__PUBLIC_MANA_AUTH_URL__ set by the page loader
* 2. process.env.PUBLIC_MANA_AUTH_URL build-time env
* 3. http://localhost:3001 — dev fallback
*/
import { browser } from '$app/environment';
export function authBaseUrl(): string {
if (browser && typeof window !== 'undefined') {
const injected = (window as unknown as { __PUBLIC_MANA_AUTH_URL__?: string })
.__PUBLIC_MANA_AUTH_URL__;
if (injected) return injected.replace(/\/$/, '');
}
return (process.env.PUBLIC_MANA_AUTH_URL || 'http://localhost:3001').replace(/\/$/, '');
}
/**
* Fetch against the mana-auth server. Always sends cookies (credentials
* include) so Better Auth can resolve the session. Path must start with
* a slash e.g. `/api/auth/organization/list`.
*/
export function authFetch(path: string, init: RequestInit = {}): Promise<Response> {
const url = `${authBaseUrl()}${path.startsWith('/') ? path : `/${path}`}`;
return fetch(url, {
credentials: 'include',
...init,
headers: {
...(init.body ? { 'content-type': 'application/json' } : {}),
...(init.headers ?? {}),
},
});
}

View file

@ -33,3 +33,5 @@ export {
} from './scoped-db';
export { applyVisibility, isVisibleToCurrentUser, type Visibility } from './visibility';
export { authFetch, authBaseUrl } from './auth-fetch';

View file

@ -12,7 +12,7 @@
*/
import { onMount } from 'svelte';
import { getActiveSpace } from '$lib/data/scope';
import { getActiveSpace, authFetch } from '$lib/data/scope';
import { SPACE_TYPE_LABELS } from '@mana/shared-branding';
interface Member {
@ -52,13 +52,11 @@
loadError = null;
try {
const [memRes, invRes] = await Promise.all([
fetch(
`/api/auth/organization/list-members?organizationId=${encodeURIComponent(activeSpace.id)}`,
{ credentials: 'include' }
authFetch(
`/api/auth/organization/list-members?organizationId=${encodeURIComponent(activeSpace.id)}`
),
fetch(
`/api/auth/organization/list-invitations?organizationId=${encodeURIComponent(activeSpace.id)}`,
{ credentials: 'include' }
authFetch(
`/api/auth/organization/list-invitations?organizationId=${encodeURIComponent(activeSpace.id)}`
),
]);
if (memRes.ok) {
@ -92,10 +90,8 @@
inviteError = null;
inviteSuccess = null;
try {
const res = await fetch('/api/auth/organization/invite-member', {
const res = await authFetch('/api/auth/organization/invite-member', {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
email: inviteEmail.trim(),
role: inviteRole,
@ -117,10 +113,8 @@
}
async function cancelInvitation(id: string) {
const res = await fetch('/api/auth/organization/cancel-invitation', {
const res = await authFetch('/api/auth/organization/cancel-invitation', {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ invitationId: id }),
});
if (res.ok) await refresh();
@ -129,10 +123,8 @@
async function removeMember(userId: string) {
if (!activeSpace) return;
if (!confirm('Mitglied wirklich entfernen?')) return;
const res = await fetch('/api/auth/organization/remove-member', {
const res = await authFetch('/api/auth/organization/remove-member', {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ memberIdOrEmail: userId, organizationId: activeSpace.id }),
});
if (res.ok) await refresh();

View file

@ -16,7 +16,7 @@
import { authStore } from '$lib/stores/auth.svelte';
import { SPACE_TYPE_LABELS } from '@mana/shared-branding';
import { isSpaceType, type SpaceType } from '@mana/shared-types';
import { loadActiveSpace } from '$lib/data/scope';
import { loadActiveSpace, authFetch } from '$lib/data/scope';
interface InvitationPayload {
id: string;
@ -52,9 +52,8 @@
loading = true;
loadError = null;
try {
const res = await fetch(
`/api/auth/organization/get-invitation?id=${encodeURIComponent(invitationId)}`,
{ credentials: 'include' }
const res = await authFetch(
`/api/auth/organization/get-invitation?id=${encodeURIComponent(invitationId)}`
);
if (!res.ok) {
throw new Error(`Einladung nicht gefunden (${res.status})`);
@ -82,20 +81,16 @@
submitting = true;
actionError = null;
try {
const res = await fetch('/api/auth/organization/accept-invitation', {
const res = await authFetch('/api/auth/organization/accept-invitation', {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ invitationId }),
});
if (!res.ok) {
throw new Error(await res.text());
}
// Activate the newly-joined space so the dashboard opens inside it.
await fetch('/api/auth/organization/set-active', {
await authFetch('/api/auth/organization/set-active', {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ organizationId: invitation.organizationId }),
});
await loadActiveSpace({ force: true });
@ -111,10 +106,8 @@
submitting = true;
actionError = null;
try {
const res = await fetch('/api/auth/organization/reject-invitation', {
const res = await authFetch('/api/auth/organization/reject-invitation', {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ invitationId }),
});
if (!res.ok) throw new Error(await res.text());